diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2fbe37e918..3113545559 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this, ### Checklist +- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable) - [ ] One focused change - [ ] `npm run typecheck` passes - [ ] `npm run lint` passes diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index c156ec0a16..833820ff2d 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -34,6 +34,7 @@ hub: - "packages/server/src/server/hub/**" server: + - "plugins/**" - "packages/server/**" - "packages/app/e2e/support/fixtures/recording.*" @@ -50,11 +51,26 @@ app: - "packages/expo-two-way-audio/**" sdk: + - "packages/plugin/**" + - "plugin-examples/**" + - "public-docs/plugins/**" - "packages/client/**" - "packages/highlight/**" - "packages/protocol/**" browser: + - "packages/server/src/server/agent/provider-snapshot-manager.ts" + - "packages/server/src/server/session/provider/provider-catalog-session.ts" + - "packages/client/src/compat/normalize-provider-models.ts" + - "packages/protocol/src/client-capabilities.ts" + - "packages/server/src/server/agent/provider-registry.ts" + - "packages/server/src/server/agent/agent-sdk-types.ts" + - "packages/server/src/server/agent/providers/codex-app-server-agent.ts" + - "packages/server/src/server/agent/providers/claude/agent.ts" + - "packages/server/src/server/agent/plugin-provider.ts" + - "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts" + - "packages/server/src/executable-resolution/**" + - "packages/plugin/src/server/provider.ts" - "packages/app/src/!(desktop)/**" - "packages/app/e2e/browser/**" - "packages/app/e2e/support/**" diff --git a/.github/workflows/android-apk-release.yml b/.github/workflows/android-apk-release.yml index 08eeccd340..a02f0dba0c 100644 --- a/.github/workflows/android-apk-release.yml +++ b/.github/workflows/android-apk-release.yml @@ -20,7 +20,50 @@ env: SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} jobs: + ensure-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: scripts + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Resolve release tag + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Ensure GitHub release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then + exit 0 + fi + + release_args=( + release create "$RELEASE_TAG" + --repo "${{ github.repository }}" + --title "Paseo $RELEASE_TAG" + --notes "" + --draft + ) + + if [[ "$IS_PRERELEASE" == "true" ]]; then + release_args+=(--prerelease) + fi + + if ! gh "${release_args[@]}"; then + echo "Release creation raced with another workflow; continuing." + node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null + fi + publish-android-apk: + needs: [ensure-release] permissions: contents: write packages: read @@ -36,32 +79,6 @@ jobs: shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" - - name: Ensure GitHub release exists - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then - exit 0 - fi - - release_args=( - release create "$RELEASE_TAG" - --repo "${{ github.repository }}" - --title "Paseo $RELEASE_TAG" - --notes "" - ) - - if [[ "$IS_PRERELEASE" == "true" ]]; then - release_args+=(--prerelease) - fi - - if ! gh "${release_args[@]}"; then - echo "Release creation raced with another workflow; continuing." - fi - - name: Setup Node uses: actions/setup-node@v4 with: @@ -129,4 +146,5 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}" + release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" + gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fa5c814c..0e3f65f336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,15 +52,13 @@ jobs: filters: .github/ci-paths.yml - name: Validate CI contracts - run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs + run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs format: name: format needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -80,8 +78,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -107,8 +103,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -123,9 +117,21 @@ jobs: - name: Build server stack run: npm run build:server + - name: Verify built-in plugins from built output + run: node --test scripts/builtin-plugins-dist.test.mjs + - name: Typecheck all packages run: npm run typecheck + - name: Run website tests + run: npx vitest run packages/website/src --bail=1 + + - name: Install website test browsers + run: npx playwright install --with-deps chromium webkit + + - name: Verify website mockups + run: npm run test:e2e --workspace=@getpaseo/website + - name: Verify public package contents run: | npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol @@ -137,8 +143,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: &server_test_steps - uses: actions/checkout@v4 with: @@ -171,6 +175,10 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + - name: Run built-in plugin tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} + run: npm run test --workspace=@getpaseo/builtin-plugins + - name: Run Hub CLI/daemon contract if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }} run: npm run test:hub-cli-contract --workspace=@getpaseo/server @@ -180,15 +188,44 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: windows-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: *server_test_steps + # native-recursive.ts serves darwin and win32, but windows-latest above is the only + # runner that exercises it. macOS — the platform the reported watcher bug was on — + # otherwise has no server-test coverage. Scoped to the observer/watcher suites to + # bound runtime. + server-tests-macos: + name: server-tests (macos-14, file observation) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: node scripts/npm-retry.mjs ci + + - name: Build server dependencies + run: npm run build:server-deps + + - name: Run file observation tests + run: | + npm run test:unit --workspace=@getpaseo/server -- \ + src/server/file-observer \ + src/server/workspace-git-service.observation.test.ts \ + src/server/workspace-git-service.observation.integration.test.ts \ + --bail=1 + desktop-tests-ubuntu: name: desktop-tests (ubuntu-latest) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -211,12 +248,35 @@ jobs: - name: Install virtual display if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} run: npx playwright install chromium + - name: Verify Windows daemon process control + if: ${{ runner.os == 'Windows' }} + run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home" + + - name: Run desktop lifecycle E2E (Linux) + if: ${{ runner.os == 'Linux' }} + run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + env: + PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle + + - name: Upload desktop lifecycle screenshots + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() && runner.os == 'Linux' }} + with: + name: desktop-lifecycle + path: ${{ runner.temp }}/desktop-lifecycle + if-no-files-found: ignore + retention-days: 7 + + - name: Run desktop lifecycle E2E (Windows) + if: ${{ runner.os == 'Windows' }} + run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + - name: Run desktop renderer E2E if: ${{ runner.os == 'Linux' }} run: npm run test:e2e:renderer --workspace=@getpaseo/desktop @@ -236,23 +296,6 @@ jobs: if-no-files-found: ignore retention-days: 7 - - name: Build and smoke unpacked desktop app - if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 --dir - env: - EP_GH_IGNORE_TIME: true - PASEO_DESKTOP_SMOKE: "1" - PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke - - - name: Upload packaged smoke diagnostics - uses: actions/upload-artifact@v4 - if: ${{ failure() && runner.os == 'Linux' }} - with: - name: desktop-packaged-smoke-linux-x64 - path: ${{ runner.temp }}/desktop-smoke - if-no-files-found: ignore - retention-days: 7 - desktop-tests-windows: name: desktop-tests (windows-latest) needs: changes @@ -268,8 +311,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -296,8 +337,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -310,7 +349,13 @@ jobs: run: node scripts/npm-retry.mjs ci - name: Build client dependencies - run: npm run build:client + run: npm run build:relay && npm run build:client + + - name: Build plugin SDK + run: npm run build:plugin + + - name: Run plugin SDK tests + run: npm run test --workspace=@getpaseo/plugin - name: Run protocol tests run: npm run test --workspace=@getpaseo/protocol @@ -330,7 +375,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "1/4" PLAYWRIGHT_ARTIFACT: "1" steps: &playwright_test_steps @@ -348,12 +392,12 @@ jobs: timeout-minutes: 10 run: npx playwright install chromium - - name: Build app dependencies - run: npm run build:app-deps - - name: Build server stack run: npm run build:server + - name: Build native app dependency + run: npm run build --workspace=@getpaseo/expo-two-way-audio + - name: Install agent CLIs for provider tests run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai @@ -361,6 +405,9 @@ jobs: run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # Weight file groups by observed duration; raw test counts overload + # early agent/Changes specs. Required check names remain unchanged. + PWTEST_SHARD_WEIGHTS: "111:172:137:160" - name: Upload test artifacts uses: actions/upload-artifact@v4 @@ -378,7 +425,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "2/4" PLAYWRIGHT_ARTIFACT: "2" steps: *playwright_test_steps @@ -389,7 +435,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "3/4" PLAYWRIGHT_ARTIFACT: "3" steps: *playwright_test_steps @@ -400,7 +445,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "4/4" PLAYWRIGHT_ARTIFACT: "4" steps: *playwright_test_steps @@ -410,8 +454,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -435,7 +477,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -452,14 +493,18 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci - - name: Build server stack - run: npm run build:server - - name: Install agent CLIs for provider tests run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - - name: Run CLI tests - run: npm run test --workspace=@getpaseo/cli + # The local runner builds its dependencies and shards its own E2E files. + - name: Run CLI E2E tests + run: npm run test:local --workspace=@getpaseo/cli + + # Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the + # shortest E2E shard, after its runner has built the server stack. + - name: Run CLI source tests + if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }} + run: npm run test:unit --workspace=@getpaseo/cli cli-tests-2: name: cli-tests (shard 2/3) @@ -467,7 +512,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -481,7 +525,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index cd20556182..f99dabaea9 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -7,6 +7,7 @@ on: - "CHANGELOG.md" - "public-docs/**" - "packages/website/**" + - "packages/protocol/**" - "package.json" - "package-lock.json" - "patches/**" @@ -29,7 +30,10 @@ jobs: cache: "npm" - name: Install dependencies - run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root + run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root + + - name: Build protocol + run: npm run build:protocol - name: Typecheck run: npm run typecheck --workspace=@getpaseo/website diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml new file mode 100644 index 0000000000..c30eea7da4 --- /dev/null +++ b/.github/workflows/desktop-packages.yml @@ -0,0 +1,91 @@ +name: Desktop Packages + +on: + push: + branches: [main] + # Packaging leaves ordinary PR CI, but a PR that changes what gets packaged + # or how it is smoked still proves the real artifacts before merge. + pull_request: + branches: [main] + paths: + - "packages/desktop/**" + - "plugins/**" + - "nix/**" + - ".github/workflows/desktop-packages.yml" + +concurrency: + group: desktop-packages-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + ONNXRUNTIME_NODE_INSTALL: skip + +jobs: + linux: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: node scripts/npm-retry.mjs ci + + - name: Install package smoke dependencies + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm + + - name: Build Linux desktop artifacts + # Smoke package contents and installation without spending minutes on xz compression. + run: | + printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json" + npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json" + env: + EP_GH_IGNORE_TIME: true + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke + + - name: Smoke installed artifacts with restricted user namespaces + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + if unshare --user --map-root-user --net true; then + echo "Expected the launching user's namespace probe to be denied" + exit 1 + fi + sudo apt-get install -y ./packages/desktop/release/*.deb + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted + + - name: Smoke sandbox-preserving portable launches + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + unshare --user --map-root-user --net true + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns + + - name: Smoke installed RPM with restricted user namespaces + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + sudo dpkg --remove paseo + # Ubuntu supplies the runtime libraries under Debian package names. + # RPM still performs the real payload installation and postinstall. + sudo rpm --install --nodeps packages/desktop/release/*.rpm + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm + + - name: Upload packaged smoke diagnostics + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: ${{ !cancelled() }} + with: + name: desktop-packaged-smoke-linux-x64 + path: ${{ runner.temp }}/desktop-smoke + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 8f55263366..f6b07c8b37 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -75,7 +75,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then + if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then echo "Release $RELEASE_TAG already exists, skipping creation" else prerelease_flag="" @@ -86,8 +86,10 @@ jobs: --repo "${{ github.repository }}" \ --title "Paseo $RELEASE_TAG" \ --notes "" \ + --draft \ $prerelease_flag || { echo "Release creation raced with another workflow; continuing." + node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null } fi @@ -100,8 +102,10 @@ jobs: include: - runner: macos-14 electron_arch: arm64 + node_heap_mb: 4096 - runner: macos-15-intel electron_arch: x64 + node_heap_mb: 8192 permissions: contents: write packages: read @@ -159,6 +163,10 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke + # Metro's jest workers OOM on the generated ws-outbound.aot.js validator, + # the last module in the web bundle. Node's default heap is sized from + # runner RAM, which is not enough on the Intel runner. + NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) @@ -183,15 +191,8 @@ jobs: run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" - files=() - while IFS= read -r -d '' f; do - files+=("$f") - done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) - if (( ${#files[@]} == 0 )); then - echo "::error::No release artifacts found in $release_dir" - exit 1 - fi - gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" + node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' @@ -258,7 +259,7 @@ jobs: NODE - name: Install Linux smoke display - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm - name: Build desktop release shell: bash @@ -275,7 +276,7 @@ jobs: npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: desktop-smoke-linux-x64 @@ -291,15 +292,8 @@ jobs: run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" - files=() - while IFS= read -r -d '' f; do - files+=("$f") - done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) - if (( ${#files[@]} == 0 )); then - echo "::error::No release artifacts found in $release_dir" - exit 1 - fi - gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" + node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' @@ -396,15 +390,8 @@ jobs: run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" - files=() - while IFS= read -r -d '' f; do - files+=("$f") - done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) - if (( ${#files[@]} == 0 )); then - echo "::error::No release artifacts found in $release_dir" - exit 1 - fi - gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" + node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' @@ -424,7 +411,7 @@ jobs: finalize-rollout: needs: [publish-macos, publish-linux, publish-windows] - if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} + if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} permissions: contents: write runs-on: ubuntu-latest @@ -480,7 +467,7 @@ jobs: name: windows-manifest path: release-manifests/windows-manifest - - name: Assemble and upload stamped manifests + - name: Assemble manifests and publish release if: env.IS_SMOKE_TAG != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -491,6 +478,33 @@ jobs: manifests_dir="$PWD/final" mkdir -p "$manifests_dir" + expected_manifests=( + "${RELEASE_CHANNEL}-mac.yml" + "${RELEASE_CHANNEL}-linux.yml" + "${RELEASE_CHANNEL}.yml" + ) + release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)" + existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')" + for manifest_name in "${expected_manifests[@]}"; do + if grep -Fxq "$manifest_name" <<< "$existing_assets"; then + case "$manifest_name" in + "${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;; + "${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;; + "${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;; + esac + if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then + gh release download "$release_lookup" \ + --repo "${{ github.repository }}" \ + --pattern "$manifest_name" \ + --dir "$manifests_dir" + else + gh release delete-asset "$release_lookup" "$manifest_name" \ + --repo "${{ github.repository }}" \ + --yes + fi + fi + done + if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then manifest_name="${RELEASE_CHANNEL}-mac.yml" node ../scripts/merge-mac-manifest.mjs \ @@ -515,25 +529,32 @@ jobs: fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" - ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' - const yaml = require("js-yaml"); - const fs = require("fs"); - const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); - const expectedDate = process.env.RELEASE_DATE_EXPECTED; - if (!Number.isFinite(expectedHours) || expectedHours < 0) { - throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); - } - for (const f of process.argv.slice(1)) { - const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; - if (m.rolloutHours !== expectedHours) { - throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); - } - if (m.releaseDate !== expectedDate) { - throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); - } - if (typeof m.version !== "string" || m.version.length === 0) { - throw new Error(`${f}: missing or invalid version`); - } - } - ' "${files[@]}" - gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + node ../scripts/validate-desktop-manifests.mjs \ + --release-date "$timestamp" \ + --rollout-hours "$ROLLOUT_HOURS" \ + "${files[@]}" + gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" + + failed_builds=() + [[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS) + [[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux) + [[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows) + if (( ${#failed_builds[@]} > 0 )); then + echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft" + exit 1 + fi + + missing_manifests=() + for manifest_name in "${expected_manifests[@]}"; do + [[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name") + done + if (( ${#missing_manifests[@]} > 0 )); then + echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft" + exit 1 + fi + + publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}") + if [[ "$IS_PRERELEASE" == "true" ]]; then + publish_args+=(--prerelease) + fi + gh "${publish_args[@]}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc5221fa6e..aa53f3ec81 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,27 +1,6 @@ name: Docker on: - pull_request: - branches: [main] - paths: - - "docker/**" - - ".dockerignore" - - ".github/workflows/docker.yml" - - "package.json" - - "package-lock.json" - - "patches/**" - - "scripts/**" - - "tsconfig.json" - - "tsconfig.base.json" - - "packages/app/**" - - "packages/cli/**" - - "packages/client/**" - - "packages/expo-two-way-audio/**" - - "packages/highlight/**" - - "packages/plugin/**" - - "packages/protocol/**" - - "packages/relay/**" - - "packages/server/**" push: branches: [main] tags: @@ -51,7 +30,7 @@ on: concurrency: group: docker-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false env: REGISTRY: ghcr.io diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index bf198d1c94..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,27 +1,8 @@ name: Nix on: - pull_request: + push: branches: [main] - paths: - - "nix/**" - - "flake.nix" - - "flake.lock" - - "package.json" - - "package-lock.json" - - "packages/app/**" - - "packages/expo-two-way-audio/**" - - "packages/highlight/**" - - "packages/plugin/**" - - "packages/protocol/**" - - "packages/client/**" - - "packages/server/**" - - "packages/relay/**" - - "packages/cli/**" - - "scripts/build-daemon-web-ui.mjs" - - "scripts/update-nix.sh" - - "scripts/fix-lockfile.mjs" - - ".github/workflows/nix.yml" permissions: contents: read @@ -31,8 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@v4 with: @@ -57,7 +36,7 @@ jobs: PASEO_HOME="$(mktemp -d)" export PASEO_LISTEN=127.0.0.1:6767 - WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + WRAPPER_LOG="$PASEO_HOME/paseo-cli.log" cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -68,7 +47,7 @@ jobs: } trap cleanup EXIT - PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 & + PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 & DAEMON_PID=$! deadline=$((SECONDS + 30)) @@ -78,7 +57,22 @@ jobs: echo "$STATUS_JSON" curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html" [[ -s "$PASEO_HOME/web-ui.html" ]] - exit 0 + if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then + break + fi + TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")" + ./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter + deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \ + | jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then + ./result/bin/paseo terminal kill "$TERMINAL_ID" + exit 0 + fi + sleep 1 + done + echo "Nix terminal did not execute the command." + break fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -93,7 +87,7 @@ jobs: cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" @@ -110,8 +104,6 @@ jobs: runs-on: macos-14 steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - uses: cachix/install-nix-action@v31 with: diff --git a/.github/workflows/release-notes-sync.yml b/.github/workflows/release-notes-sync.yml index 545657f678..f95e3724fe 100644 --- a/.github/workflows/release-notes-sync.yml +++ b/.github/workflows/release-notes-sync.yml @@ -15,7 +15,7 @@ on: required: false type: string create_if_missing: - description: "Create release if missing (normally only needed for tag events)." + description: "Create a draft release if missing (normally only needed for tag events)." required: false default: false type: boolean @@ -29,7 +29,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - steps: - uses: actions/checkout@v4 with: diff --git a/.gitignore b/.gitignore index 5298013fdc..6d6bb279b7 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,9 @@ packages/protocol/src/generated/validation/*.aot.ts packages/desktop/.cache/ packages/desktop/src-tauri/resources/managed-runtime/ app.json + +# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging. +*.heapsnapshot + +# Project-local Serena configuration and memories. +.serena/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 269097aab4..4582602a97 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,45 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": [ + "plugins/*-provider/server/**/*.ts", + "packages/plugin/src/server/acp-internal/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + }, + { + "name": "child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { @@ -213,7 +252,6 @@ "packages/app/src/components/download-toast.tsx", "packages/app/src/components/draggable-list.native.tsx", "packages/app/src/components/headers/back-header.tsx", - "packages/app/src/components/headers/menu-header.tsx", "packages/app/src/components/headers/screen-header.tsx", "packages/app/src/components/host-status-dot.tsx", "packages/app/src/components/hosts/host-picker.tsx", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8848d08552..0e2b24ac5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,637 @@ # Changelog +## 0.11.2 - 2026-10-09 + +### Fixed + +- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445)) + +## 0.11.1 - 2026-10-07 + +### Added + +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332)) + +### Fixed + +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308)) +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316)) +- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301)) + +## 0.11.0 - 2026-10-07 + +Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry). + +**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory. + +### Added + +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) +- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216)) +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156)) +- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) +- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) +- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) +- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680)) +- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1)) +- Added `agents.providers..options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780)) +- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) + +### Plugins + +- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224)) +- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) +- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976)) +- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood)) +- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) +- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263)) +- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226)) + +### Changed + +- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185)) +- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot)) +- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942)) +- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090)) +- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087)) +- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044)) +- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750)) +- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340)) + +### Fixed + +- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer)) +- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson)) +- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272)) +- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248)) +- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268)) +- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022)) +- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074)) +- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov)) +- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273)) +- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228)) +- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045)) +- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) +- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx)) +- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD)) +- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil)) +- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork)) +- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) +- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) +- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) +- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112)) +- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115)) +- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215)) +- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061)) +- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) +- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755)) +- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826)) +- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) +- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul)) +- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188)) +- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum)) +- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988)) +- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684)) +- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686)) +- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758)) +- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774)) +- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804)) +- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796)) +- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172)) +- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970)) +- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138)) +- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158)) +- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195)) +- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968)) +- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207)) +- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219)) +- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd)) +- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq)) +- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn)) +- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen)) +- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu)) +- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio)) +- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone)) +- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart)) +- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898)) +- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922)) +- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279)) +- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117)) +- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097)) +- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095)) +- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694)) +- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255)) +- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) +- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart)) +- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722)) +- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585)) +- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058)) +- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) +- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) +- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) +- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) +- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053)) +- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) +- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009)) +- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet)) +- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) +- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579)) +- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239)) +- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) +- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) +- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) +- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815)) + +## 0.10.3 - 2026-10-02 + +### Added + +- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753)) + +## 0.10.2 - 2026-09-30 + +### Fixed + +- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674)) +- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger)) +- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679)) +- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696)) +- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609)) + +## 0.10.1 - 2026-09-29 + +### Added + +- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi)) + +### Fixed + +- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587)) +- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg)) +- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577)) +- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451)) +- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572)) +- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450)) +- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386)) +- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987)) +- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379)) + +## 0.10.0 - 2026-09-28 + +### Added + +- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep)) +- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull)) + +### Changed + +- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459)) + +### Fixed + +- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338)) +- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434)) +- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445)) +- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid)) +- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394)) +- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437)) +- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446)) +- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372)) +- Fixed `paseo --host run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392)) +- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383)) +- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432)) +- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343)) +- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415)) +- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411)) +- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407)) +- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347)) +- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506)) +- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374)) +- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341)) +- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358)) + +## 0.9.2 - 2026-09-24 + +### Added + +- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206)) + +### Improved + +- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170)) +- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190)) + +### Fixed + +- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172)) +- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346)) +- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB)) +- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai)) +- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227)) +- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238)) +- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229)) +- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249)) +- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221)) +- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322)) +- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317)) +- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer)) +- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245)) +- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281)) +- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton)) +- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285)) +- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289)) +- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286)) +- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258)) +- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243)) +- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235)) +- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion)) +- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS)) +- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen)) +- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9)) +- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248)) +- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253)) +- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231)) +- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298)) +- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302)) +- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301)) +- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306)) +- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277)) +- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315)) +- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332)) +- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337)) +- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310)) +- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335)) +- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305)) +- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290)) +- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258)) +- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224)) +- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272)) +- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287)) + +## 0.9.1 - 2026-09-22 + +### Added + +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri)) + +### Fixed + +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu)) +- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200)) + +## 0.9.0 - 2026-09-22 + +### Added + +- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files ([#4589](https://github.com/getpaseo/paseo/pull/4589)) +- Added Cmd/Ctrl+F Find to terminal scrollback ([#4650](https://github.com/getpaseo/paseo/pull/4650)) +- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window ([#4765](https://github.com/getpaseo/paseo/pull/4765)) +- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet ([#4861](https://github.com/getpaseo/paseo/pull/4861)) +- Added automatic Pull request tab opening once per workspace when a PR is detected ([#4956](https://github.com/getpaseo/paseo/pull/4956)) +- Added expandable plan cards, with rejected plans collapsed by default ([#4756](https://github.com/getpaseo/paseo/pull/4756)) +- Added an attachment placeholder with a spinner while a selected file uploads ([#4958](https://github.com/getpaseo/paseo/pull/4958)) +- Added match highlighting to the workspace, agent, project, and branch fields in History search ([#4945](https://github.com/getpaseo/paseo/pull/4945)) + +### Plugins + +- Added plugin installation from npm, including scoped packages, versions, tags, and ranges ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Added `paseo plugin update` with `--all`, `--check`, and `--yes`, showing the current and proposed revision before approval ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Added each plugin's description, source, and installed revision to Settings → Plugins ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Added an optional `serverId` to `navigation.openAgent()` and `navigation.openWorkspace()` ([#4942](https://github.com/getpaseo/paseo/pull/4942)) +- Added host discovery through `useHosts()` and host-targeted SDK clients through `getPaseoClient(serverId)` ([#4971](https://github.com/getpaseo/paseo/pull/4971)) +- Added `openExternalUrl()` and `` for opening a URL outside Paseo ([#4972](https://github.com/getpaseo/paseo/pull/4972)) +- Added `navigation.openBrowser()` for opening a URL in a workspace browser on desktop ([#4972](https://github.com/getpaseo/paseo/pull/4972)) +- Added a server settings handle returned by `registerSettings()` with `read()` and `subscribe()` ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger)) +- Changed `assistant_message` transformers to receive the whole accumulated message on each update ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork)) +- Changed `tool_call` transformers to receive every original call before Overview groups them ([#4675](https://github.com/getpaseo/paseo/pull/4675)) +- Fixed a plugin session losing its host API permanently after its heartbeat lease expired ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio)) +- Fixed plugin build commands failing with `spawn npm ENOENT` on Windows ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati)) +- Fixed ACP text chunks without a `messageId` splitting one reply into a message per chunk ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB)) +- Fixed nested provider subagents appearing as direct children of the root agent ([#4970](https://github.com/getpaseo/paseo/pull/4970)) + +### Changed + +- Changed History search to keep results chronological in date buckets instead of reordering by relevance ([#4945](https://github.com/getpaseo/paseo/pull/4945)) +- Changed workspace Restore to keep the archived agent selected, with its own Unarchive action ([#4736](https://github.com/getpaseo/paseo/pull/4736)) +- Changed the workspace error screen's Retry to Reload, which reopens at the project picker ([#4598](https://github.com/getpaseo/paseo/pull/4598)) +- Changed closing the last content tab to leave the New launcher instead of an unusable pane ([#4844](https://github.com/getpaseo/paseo/pull/4844)) +- Changed `paseo daemon start` to read persistent configuration; removed configuration flags fail with migration instructions ([#4575](https://github.com/getpaseo/paseo/pull/4575)) + +### Improved + +- Bold, italics, strikethrough, inline code, and link labels stay formatted while a reply streams ([#4742](https://github.com/getpaseo/paseo/pull/4742)) +- Reduced time to first voice audio from 4.80s to 0.95s on a three-sentence reply ([#4927](https://github.com/getpaseo/paseo/pull/4927)) +- Reduced cold diff generation from 11.45s to 2.65s on a 213-file workspace ([#4676](https://github.com/getpaseo/paseo/pull/4676)) +- Reduced desktop memory use, from 290.5 MiB to 152.1 MiB RSS in the Electron main process after loading daemon management ([#5007](https://github.com/getpaseo/paseo/pull/5007)) +- Kept open chats subscribed across view eviction, app backgrounding, and reconnect ([#4863](https://github.com/getpaseo/paseo/pull/4863)) +- Added reconnection and Updating messages status to the chat toast ([#4863](https://github.com/getpaseo/paseo/pull/4863)) +- Kept the app responsive during large uploads by yielding between 128 KiB chunks ([#4958](https://github.com/getpaseo/paseo/pull/4958)) +- Let resident browser pages idle between screenshots while the desktop window is hidden ([#4646](https://github.com/getpaseo/paseo/pull/4646)) + +### Fixed + +- Fixed the daemon exhausting its heap in long conversations with cumulative tool output ([#4838](https://github.com/getpaseo/paseo/pull/4838)) +- Fixed repeated submissions creating several workspaces or agents for one intent ([#4442](https://github.com/getpaseo/paseo/pull/4442)) +- Fixed a crash loop after closing the last content tab in a workspace ([#4844](https://github.com/getpaseo/paseo/pull/4844)) +- Fixed New Agent crashing when an enabled provider published duplicate model IDs ([#4839](https://github.com/getpaseo/paseo/pull/4839)) +- Fixed long drafts growing behind the chat header on Android and iOS ([#4824](https://github.com/getpaseo/paseo/pull/4824)) +- Fixed the Android composer growing taller when the keyboard closed ([#4902](https://github.com/getpaseo/paseo/pull/4902)) +- Fixed the Android composer keeping its height after hold-to-delete emptied a draft ([#4946](https://github.com/getpaseo/paseo/pull/4946)) +- Fixed New workspace setup content not moving up as the composer grows ([#4973](https://github.com/getpaseo/paseo/pull/4973)) +- Fixed the Android timeline scrolling away when tapping or selecting text in a chat ([#5013](https://github.com/getpaseo/paseo/pull/5013)) +- Fixed Paste image failing before an attachment reached the composer on Android ([#4758](https://github.com/getpaseo/paseo/pull/4758)) +- Fixed provider and model pickers not responding on Android tablets ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl)) +- Fixed Linux desktop packages launching without the Chromium sandbox ([#4447](https://github.com/getpaseo/paseo/pull/4447)) +- Fixed `paseo daemon stop` shutting down a daemon other than the selected one ([#4575](https://github.com/getpaseo/paseo/pull/4575)) +- Fixed restored archived workspaces showing empty Changes and Commits ([#4926](https://github.com/getpaseo/paseo/pull/4926)) +- Fixed a rejected plan appearing below the follow-up message that rejected it ([#4756](https://github.com/getpaseo/paseo/pull/4756)) +- Fixed Cmd/Ctrl+F not opening chat Find while the composer had focus ([#4991](https://github.com/getpaseo/paseo/pull/4991)) +- Fixed the linked pull request going undetected when a branch remote is a repository URL ([#4862](https://github.com/getpaseo/paseo/pull/4862)) +- Fixed a plugin's filtered agent list replacing the app's own directory subscription ([#4596](https://github.com/getpaseo/paseo/pull/4596)) +- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737)) +- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895)) +- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix)) +- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme)) +- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413)) +- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927)) +- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945)) +- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698)) +- Fixed the Explorer showing `+0 -0` and "No changes" while being dragged open ([#4861](https://github.com/getpaseo/paseo/pull/4861)) +- Fixed the reconnect toast restarting its entrance animation when opening a saved chat ([#4925](https://github.com/getpaseo/paseo/pull/4925)) +- Fixed raised shadows around Android file rows in the changed-files sheet ([#4898](https://github.com/getpaseo/paseo/pull/4898)) + +## 0.8.0 - 2026-09-10 + +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. + +**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API. + +### Added + +- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587)) +- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj)) +- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576)) +- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203)) +- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) +- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) +- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229)) +- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo)) +- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) +- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215)) +- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242)) + +### Plugins + +- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314)) +- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger)) +- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357)) +- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392)) +- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358)) +- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) +- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) +- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov)) +- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430)) +- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) +- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265)) +- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446)) + +### Changed + +- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Changed `--host` to a global CLI option: `paseo --host ` ([#4238](https://github.com/getpaseo/paseo/pull/4238)) +- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517)) +- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640)) +- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) + +### Improved + +- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275)) +- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574)) +- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470)) +- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322)) + +### Fixed + +- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201)) +- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207)) +- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389)) +- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469)) +- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436)) +- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10)) +- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl)) +- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter)) +- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401)) +- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321)) +- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) +- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335)) +- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553)) +- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240)) +- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199)) +- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210)) +- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228)) +- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208)) +- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) +- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222)) + +## 0.7.2 - 2026-09-02 + +### Added + +- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger)) +- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158)) + +### Improved + +- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190)) + +### Fixed + +- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174)) +- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190)) +- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166)) +- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119)) +- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161)) +- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168)) +- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g)) +- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170)) +- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171)) + +## 0.7.1 - 2026-09-01 + +### Added + +- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179)) + +## 0.7.0 - 2026-08-31 + +### Changed + +- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944)) +- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896)) +- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111)) + +### Added + +- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920)) +- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940)) +- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque)) +- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter)) +- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956)) +- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967)) +- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066)) +- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064)) +- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky)) +- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet)) +- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet)) +- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet)) +- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet)) +- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter)) +- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio)) +- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm)) +- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997)) + +### Improved + +- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2)) +- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907)) +- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009)) +- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai)) +- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx)) +- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025)) + +### Fixed + +- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048)) +- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044)) +- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051)) +- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090)) +- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067)) +- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069)) +- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065)) +- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968)) +- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim)) +- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger)) +- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033)) +- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987)) +- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975)) +- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude)) +- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945)) +- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008)) +- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976)) +- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005)) +- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068)) +- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter)) +- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642)) +- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946)) +- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942)) +- Fixed ACP agents reporting the terminal capability as unavailable ([#3910](https://github.com/getpaseo/paseo/pull/3910) by [@pmilanez](https://github.com/pmilanez)) +- Fixed canceled Claude permission requests leaving stale permission cards ([#3792](https://github.com/getpaseo/paseo/pull/3792)) +- Fixed plugin daemon sessions hiding agents from non-legacy providers ([#3902](https://github.com/getpaseo/paseo/pull/3902) by [@omercnet](https://github.com/omercnet)) +- Fixed manually admitted desktop updates disappearing during readiness checks ([#3865](https://github.com/getpaseo/paseo/pull/3865)) +- Fixed mobile Changes and pull-request actions opening outside Explorer ([#3867](https://github.com/getpaseo/paseo/pull/3867)) +- Fixed older desktop builds deleting settings written by newer builds ([#3909](https://github.com/getpaseo/paseo/pull/3909) by [@cleiter](https://github.com/cleiter)) +- Fixed composer height flashing after returning from Settings ([#3943](https://github.com/getpaseo/paseo/pull/3943)) +- Fixed selected workspaces having no visible highlight in the Light theme ([#3922](https://github.com/getpaseo/paseo/pull/3922) by [@wdaubney](https://github.com/wdaubney)) +- Fixed the scrolling diff order disagreeing with the Changes tree ([#3913](https://github.com/getpaseo/paseo/pull/3913) by [@cleiter](https://github.com/cleiter)) +- Fixed incorrect Spanish translations for Close actions ([#3934](https://github.com/getpaseo/paseo/pull/3934) by [@antonio](https://github.com/antonio)) + +## 0.6.1 - 2026-08-25 + +### Improved + +- Command Center now matches partial multi-word queries in any order and ranks visible results by relevance ([#2971](https://github.com/getpaseo/paseo/pull/2971) by [@cleiter](https://github.com/cleiter)) + +### Fixed + +- Fixed upgrades placing persisted Side panel tabs inside Explorer instead of an ordinary side pane ([#3861](https://github.com/getpaseo/paseo/pull/3861)) +- Fixed Escape leaving Settings open on desktop and web ([#2828](https://github.com/getpaseo/paseo/pull/2828) by [@mcowger](https://github.com/mcowger)) +- Fixed branch switches hiding the current pull request or arming auto-archive from an already-merged pull request ([#3799](https://github.com/getpaseo/paseo/pull/3799)) + +## 0.6.0 - 2026-08-25 + +### Changed + +- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826)) + +### Added + +- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826)) +- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826)) + +### Fixed + +- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814)) +- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821)) + ## 0.5.2 - 2026-08-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index b72d99731c..e1ac653338 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine. -**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi. +**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code. ## Repository map @@ -21,46 +21,48 @@ This is an npm workspace monorepo: At the start of non-trivial work, list `docs/` and skim anything relevant to the task. -| Doc | What's in it | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going | -| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow | -| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track | -| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | -| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms | -| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | -| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden | -| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example | -| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | -| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | -| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | -| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu | -| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | -| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | -| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | -| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | -| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | -| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | -| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | -| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | -| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | -| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | -| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | -| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | -| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | -| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | -| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof | -| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | -| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints | -| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference | -| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | -| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha | -| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | -| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security | -| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | -| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | -| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | -| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority | +| Doc | What's in it | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going | +| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow | +| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track | +| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | +| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms | +| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | +| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden | +| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example | +| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | +| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | +| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | +| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu | +| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | +| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | +| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | +| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | +| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | +| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | +| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | +| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | +| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | +| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | +| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | +| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | +| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | +| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | +| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | +| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof | +| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | +| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints | +| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences | +| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | +| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha | +| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata | +| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security | +| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | +| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | +| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | +| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority | ### Writing docs @@ -104,6 +106,13 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_ See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging. +## Release branches + +When the user says "this goes to next", create or +retarget the PR to `next` and preserve that destination through delivery. Follow +[release branch discipline](docs/release.md#release-branch-discipline) for creating +and updating `next`, integrating it after a release, and releasing a hotfix from a tag. + ## Critical rules - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. @@ -116,6 +125,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. - Never re-run a test suite that another agent already ran and reported green — trust the result. - For full suite verification, push to CI and check GitHub Actions instead. +- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones. - **Always run typecheck and lint after every change.** - **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current: - `npm run build:client` — rebuild protocol and client declarations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8eaaf5c0c7..b25810262f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,140 +1,104 @@ # Contributing to Paseo -Thank you for taking the time to contribute to Paseo. +This guide is here to save us both time and help you find a useful way to contribute. + +Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect. ## Philosophy -Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md). +**Paseo has a lean, opinionated core built to be extended.** -Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. +The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control. -I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it. +The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer. -## Report bugs in GitHub issues or Discord +Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops. -Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH). +## Build a plugin -> [!IMPORTANT] -> Feature requests opened as issues will get closed automatically +Most specialized workflows and integrations are better served by plugins. -If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis. +Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk). -You may submit a PR to fix a bug, please read the PR guidelines below. +If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core. -## Product discussions go in GitHub Discussions or Discord +## Report bugs -There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow: +Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template. + +Include: + +- What you did, what you expected, and what happened. +- Reproduction steps, Paseo version, and platform. +- Relevant logs, screenshots, or a recording. + +If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification. + +Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below. + +## Discuss workflows before proposing features + +Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed. + +Explain: - What are you trying to do? - How do you do it today? - Where does Paseo get in the way? -- What would the flow look like if it worked for you? +- What would a better workflow look like? -"Can you build X" is not useful because it doesn't tell me about your problem. +This helps other people describe their needs and gives me useful context when deciding what to build. -A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build. +There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying. -Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me. +## If you still want to submit a pull request -## Pull requests +After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome. -Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed. +**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation. -Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at. +Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product. -Here is the criteria I use to decide: +I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do. -✅ Likely to be accepted +If you choose to submit a PR, these are the basics to cover. -- Fits the product vision -- One focused change -- Links the bug it fixes, or the discussion behind it if there is one -- Addresses bot reviews -- Explains the problem you're solving -- QA evidence -- Automated tests -- Screenshots or video for UI changes, on every affected platform -- Says which platforms you tested and which you didn't -- Maintainer edits enabled +### What I look for in a core feature -⛔️ Will be rejected +- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin. +- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task. +- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent. +- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer. -- Bundles unrelated changes -- Fails checks -- Ignores bot reviews -- Takes a feature or the design in a direction I don't want -- No QA evidence -- No tests -- Clearly fully AI-generated PR +### Keep the scope small -### What to expect +Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own. -- PRs that were explicitly approved in a discussion are preferred. -- Unsolicited PR can be closed without a detailed review. -- Your PR can be narrowed, refactored, or redesigned. -- Your PR might be accepted but not merged immediately. -- You will be attributed for your work in the changelog, even if I redesign your PR +### QA evidence -## QA evidence +Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature. -QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar. +Explain the problem and link the relevant bug report or discussion. Include: -Pull requests without evidence will be closed. +- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version. +- The commands you ran and their output. +- A recording for interactive UI changes, or before-and-after screenshots for static changes. +- The platforms you tested and any affected platforms you could not test. -What's accepted: +The [QA guide](docs/qa.md) explains the expected evidence and available tooling. -- The shell commands you ran, pasted with their output -- The tests you added and their results -- Before and after screenshots -- A video of the whole interaction -- Logs, requests, responses +Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar. -Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static. +**Satisfying all of the above does not mean your pull request will be merged.** -The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it. +### What happens to your contribution -## I want to become a maintainer +If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.** -There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement. +A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume. -## FAQ +## Help the community -### I'm blocked, where do I go? +Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop. -Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there. - -### Do you prefer Github or Discord? - -I am more active on Discord for general questions. - -I prefer Github for bugs because the template helps collect information more efficiently. - -### Why was my feature request closed? - -Issues are only for bugs. Open it as a product discussion instead, framed as a workflow. - -### Why didn't my PR get a detailed review? - -Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume. - -### How long until someone looks at my pull request? - -It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask. - -### Why do you want tests and a video for a small change? - -Because it's faster and safer than me checking out and running every PR myself to test it manually. - -QA evidence help me take your PR seriously for a deeper review and maintainer QA. - -### I only have one platform, can I still contribute? - -Yes. Test what you have, and say what you didn't test. - -### Can I use an agent to write my contribution? - -Yes, as long as you understand what you're submitting and you tested it yourself. - -### Is there a roadmap I can read? - -[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next. +There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility. diff --git a/LICENSE b/LICENSE index 4a76727e5d..2f5903143b 100644 --- a/LICENSE +++ b/LICENSE @@ -2,670 +2,210 @@ Copyright (c) 2025-present Mohamed Boudra Portions of this software are licensed as follows: -* All third party components incorporated into the Paseo Software are - licensed under the original license provided by the owner of the - applicable component. -* All content outside of the above mentioned restrictions is available - under the "AGPLv3" license as defined below. - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +* Third-party components incorporated into Paseo remain licensed under + the licenses provided by their respective copyright holders. +* Except for those third-party components, Paseo is licensed under the + Apache License, Version 2.0, reproduced below. + + + 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 [yyyy] [name of copyright owner] + + 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. diff --git a/README.ja.md b/README.ja.md index 0f35d47c57..c2d1ad88a0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -151,12 +151,17 @@ npm run build:server npm run typecheck ``` +## スポンサー + +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。 + + + ## 関連プロジェクト - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー -- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能 ## ライセンス -AGPL-3.0 +Apache-2.0 diff --git a/README.ko.md b/README.ko.md index 9975a7dd63..60581767cd 100644 --- a/README.ko.md +++ b/README.ko.md @@ -161,6 +161,12 @@ npm run build:server npm run typecheck ``` +## 스폰서 + +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다. + + + ## 관련 프로젝트 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이 @@ -169,4 +175,4 @@ npm run typecheck ## 라이선스 -AGPL-3.0 +Apache-2.0 diff --git a/README.md b/README.md index 1b26f31800..2fc0fc8f3d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

-

One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.

+

One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.

Paseo app screenshot @@ -39,14 +39,34 @@ Paseo mobile app

-Run agents in parallel on your own machines. Ship from your phone or your desk. +Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android. +- **Parallel agents:** Run many agents at once, each in its own worktree. +- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers. +- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. -- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job. +- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins. +[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. + +## Plugins + +Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, +iOS, and Android. Write a plugin once and it is on your phone. + +- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes. +- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends. +- **Providers:** add a coding agent as a provider. + +Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory. + +**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)** + +Plugins run with access to your daemon machine and inside connected clients; install only code you trust. + ## Getting Started Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it. @@ -60,6 +80,8 @@ You need at least one agent CLI installed and configured with your credentials: - [GitHub Copilot](https://github.com/features/copilot/cli/) - [OpenCode](https://github.com/anomalyco/opencode) - [Pi](https://pi.dev) +- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity) +- [Muse Code](https://paseo.sh/docs/muse-code) ### Desktop app (recommended) @@ -67,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r To connect from your phone, open **Settings → your host → Pair Device**. -### CLI / headless +### Server -Install the CLI and start Paseo: +For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon: ```bash npm install -g @getpaseo/cli paseo ``` -Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines. +Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host. For full setup and configuration, see: @@ -111,8 +133,8 @@ paseo ls # list running agents paseo attach abc123 # stream live output paseo send abc123 "also add tests" # follow-up task -# run on a remote daemon -paseo --host workstation.local:6767 run "run the full test suite" +# run on a remote daemon; --cwd is a path on that host +paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite" ``` See the [full CLI reference](https://paseo.sh/docs/cli) for more. @@ -185,12 +207,17 @@ npm run build:server npm run typecheck ``` +## Sponsors + +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. + + + ## Related projects - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir -- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension ## License -AGPL-3.0 +Apache-2.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index fef0f4ff2c..dfdf673b2e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -151,10 +151,15 @@ npm run build:server npm run typecheck ``` +## 赞助 + +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。 + + + ## 相关项目 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写 -- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展 ### 自托管 relay TLS @@ -205,4 +210,4 @@ server { ## License -AGPL-3.0 +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md index d97910122e..ea23984fa0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,14 +42,20 @@ The daemon requires a valid cryptographic handshake before processing any comman The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. +When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available. + ## Local daemon trust boundary -By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. +By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools. -The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer ` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks. +The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens. + +The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission. Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. +When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate. + If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. In Docker, the official image runs the daemon and agents as the non-root diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md index 87176d24b4..c12f003df9 100644 --- a/docs/ad-hoc-daemon-testing.md +++ b/docs/ad-hoc-daemon-testing.md @@ -51,7 +51,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", // see gotcha #1 }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... do your testing ... @@ -81,7 +81,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... test ... diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d9f9710599..d699d8519a 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,6 +21,10 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider same Paseo agent ID. Provider history is not appended again when the canonical timeline is already primed. +Reload releases the old runtime before resuming its durable session: an idle provider process can +still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the +replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. + Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle action such as archive, replacement, reload, workspace teardown, or daemon shutdown. @@ -30,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but between turns nothing is watching, so the agent sits at `idle` looking healthy while its background work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry. -Only the Claude provider does this today; the others still report a death only when a turn happens to -be in flight. +Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt; +the unfinished turn is lost. ### Cancellation @@ -65,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. -Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace. +Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. -`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. +`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. @@ -80,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime. The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle. +## Background workspaces + +`workspace.background` controls discovery only. Background workspaces contain ordinary durable +agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery +keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot +change it. New workspaces default to false, inherit the caller workspace's value when an agent +creates one without an override, and accept explicit true or false. + +The daemon derives agent discovery from workspace ownership. Default workspace and agent lists, +including History requests, exclude background work; `filter.includeBackground` includes it. +Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only +its sidebar with **Show background**, so direct navigation and restored tabs remain available. + +Sequenced directories keep complete canonical collections. A filtered read must never replace +those collections with its subset. Inclusive and default observations have distinct cursor +scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache. +Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`. + +### Private daemon helpers + +Branch-name and commit-message generators still use private disposable internal runtimes. +`AgentManager` does not persist them or their provider sessions, excludes them from public +discovery and plugin hooks, and drops their timelines on archive. An archived helper retains +its exact-ID result briefly to cover completion before a waiter attaches. This internal +lifecycle is separate from public workspace visibility and is not a public creation option. + ## Archive Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. @@ -105,16 +135,29 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. -History navigation preserves the selected agent as an explicit recovery target. If both that agent -and its workspace are archived, the workspace recovery action restores the workspace and unarchives -the selected agent as one user action. Other archived agents in the restored workspace remain -recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative -timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must -leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains -the only transition back to an interactive runtime: it runs the provider's native unarchive hook -(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A -provider session can be archived outside Paseo while its Paseo agent remains active. Interactive -resume repairs that drift through the provider's native unarchive hook; history resume does not. +History navigation opens the selected agent without changing either archive state. Workspace +**Restore** recovers only the workspace; the selected archived agent stays open with its callout. +The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and +history hydration. Other archived agents stay archived. + +Opening an agent is a navigation choice, independent of whether its details are cached. The +layout retains that choice across reload while the panel fetches the agent from the daemon. +Once the daemon reports the agent active, its tab follows normal archive propagation again. +An empty active list cannot cancel an explicit History selection. Agent-detail loading does +not own selection or release the explicit open. + +Persisted resume, native restore, and both live and stored-only archive enter the same per-agent +lifecycle queue. Resume chooses its history or interactive purpose from the durable record after +entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed +close retains the runtime for cleanup and blocks replacement through that close operation. + +Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained +history session contains only the read results and must release its provider process once replay +finishes. Codex reads the persisted thread and child histories on a temporary app-server and never +loads, resumes, or unarchives a native thread, including legacy records whose native archive failed. +ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process +before returning. Interactive resume remains responsible for repairing a provider session archived +outside Paseo while its Paseo agent is active. Provider session connection owns every process it spawns until the session is registered with `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, @@ -144,6 +187,17 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. +A finished workspace can be marked unread after it has been reviewed. The daemon restores +`finished` attention on its newest eligible workspace-root agent without sending a new completion +notification. Opening the workspace clears that attention through the normal focus flow. + +Attention is set by the agent finishing or failing and cleared by the client's +`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is +neither, so resuming carries the stored attention and the stored last-activity time through +untouched. Forging either makes a background resume look like the user read a workspace and like the +agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted +`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. @@ -156,9 +210,9 @@ The rows combine two kinds of children: parentAgentId === thisAgent.id AND !archivedAt ``` -- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. +- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. @@ -170,11 +224,13 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta - **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent. - **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table. -- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`. +- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`. - **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline. - **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. - **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. -- **On replay, `/subagents/` holds every descendant, not just this session's children.** `agent-.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22. +- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one. +- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent. +- **On replay, `/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work. - **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path. Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md new file mode 100644 index 0000000000..b31a3fd9ee --- /dev/null +++ b/docs/agent-stream-performance.md @@ -0,0 +1,79 @@ +# Agent stream performance + +How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`. + +For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md). + +## The pipeline + +``` +provider deltas (every provider streams incrementally) + → AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent) + → recordTimeline: one canonical row per flushed item + → agent_stream ws message + → reducer queue (app, one commit per frame) → session store + → source-item plugin transforms → native Markdown blocks / tool grouping + → paced reveal (app, per displayed item) → paint +``` + +Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`. + +## Why the reveal is paced + +Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged. + +So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption. + +## Invariants + +- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`. +- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not. +- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal. +- **Markdown blocks belong to presentation, and every assistant message is a block group.** + `agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same + way whether it arrived streaming or as fetched history. Splitting in the reducer instead would + discard paragraph separators and expose fragments to plugin callbacks, which need the whole source + text. Live-head work stays cheap by parsing only the growing last block during append, retaining + completed blocks by object identity, and caching the split per source item so a tail change does + not re-split history. History was once left whole to keep cross-block Markdown context; that gave + one message two shapes depending on how it arrived, and message-addressed features only worked on + one of them. +- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with + `blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two + being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its + message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything + addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts + the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for + React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image + occurrence keys. +- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. +- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. +- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. +- **A history row re-renders only when its item or layout item identity changes.** The inverted + FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a + memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of + JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed, + `useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state, + or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history + through that hook; the web viewport once skipped it and history hosts of a live tool group went + stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing + silently stops. + +## Measuring + +- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth. +- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly. +- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer. + +Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken: + +| | paint on arrival | paced | +| -------------------------------- | ---------------- | ----- | +| frames that advanced the text | 6% | 87% | +| chars-per-frame CV | 4.11 | 1.86 | +| gap between visible updates, p50 | 317ms | 17ms | +| gap between visible updates, p95 | 383ms | 17ms | + +Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive. + +Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly. diff --git a/docs/android.md b/docs/android.md index d65caf9871..f04ce945bb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -15,7 +15,9 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app ## Version codes -`packages/app/app.config.js` derives Android `versionCode` from the package version with: +`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly. + +The base version code comes from the package version: ```text major * 1_000_000 + minor * 1_000 + patch @@ -118,6 +120,17 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. +## Inverted timeline selection + +Android focus and selection visibility requests must not reposition inverted timelines. The +`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for +child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll +commands still work; non-inverted scroll views keep Android's default behavior. + +Register this package before React Native's core package through its Expo config plugin. Normal +Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does +not change the shipped scroll view. Keep this behavior in the app's compiled native module. + ## F-Droid / source-only Android builds F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild: @@ -145,6 +158,37 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized. +### F-Droid store metadata + +F-Droid reads the store listing from `fastlane/metadata/android//` **at the repo root**. This location provides the best compatibility with the F-Droid release process. + +```text +fastlane/metadata/android/ +├── en-US/ (F-Droid fallback locale, mandatory) +│ ├── title.txt (<=50 chars) +│ ├── short_description.txt (<=80 chars) +│ ├── full_description.txt (<=4000 chars, limited HTML) +│ ├── images/ +│ │ ├── icon.png (512x512) +│ │ ├── featureGraphic.png (1024x500) +│ │ └── phoneScreenshots/ (1.png, 2.png, ...) +│ └── changelogs/ (generated — see below) +├── ja/ +└── zh-CN/ +``` + +Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`. + +F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result. + +One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes. + +Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped. + +Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at. + +Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog. + ### React version lockstep Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen. diff --git a/docs/architecture.md b/docs/architecture.md index a871dc348a..4ed25ba1c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,15 @@ The heart of Paseo. A Node.js process that: All paths are under `packages/server/src/`. +Desktop and CLI import server capabilities through explicit package subpaths such as +`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export +loads daemon bootstrap eagerly, even when a caller only uses a path or process helper. +Keep process-management and configuration dependencies independent of bootstrap and +WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules; +`messages` imports and re-exports them. This prevents every supervising process from +retaining the daemon's runtime and wire-schema allocations. The server export tests guard +these dependency trees with tree-shaking disabled to match unbundled production imports. + Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's project Git observer keeps one non-recursive watch on each lexically equivalent active project root and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs @@ -94,18 +103,19 @@ facade. App and CLI may import the low-level driver from `@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped code imports from `@getpaseo/client`. -`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config. +`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config. `PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected -host's client; plugin subprocesses use the same facade over a host-owned IPC transport. +host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow +[the client contract](protocol-compatibility.md#client-capability-ownership). ### `packages/app` — Mobile + web client (Expo) Cross-platform React Native app that connects to one or more daemons. - Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path. -- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state -- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false. -- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. +- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon. +- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`. +- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. - `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. - `SessionContext` wraps the daemon client for the active session - Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` @@ -113,14 +123,21 @@ Cross-platform React Native app that connects to one or more daemons. - Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. - Voice features: dictation (STT) and voice agent (realtime) -The replica cache paints stale data immediately while the host connects. Directory cursors are -reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers. -Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached -timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a -separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole -host snapshots in least-recently-written order; a single oversized host is omitted rather than -partially restored. Browser and Electron builds store it in IndexedDB. Native builds use -AsyncStorage, and Android reserves 64 MiB for that database. +Consumers request directory or timeline data without choosing memory, cache, or network. The owner +publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an +invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers +network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and +focused timeline row, plus the workspace and project rows needed by the route; opening a directory +reads directory rows for that host and establishes its live subscription when connected. Accepted +cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and +host connection do not create directory demand or install replicas. The directory owner retains +declared surface demand and re-establishes network reconciliation after reconnect; React does not +track connection generations. Late cache reads cannot replace state already advanced by live or +authoritative network data. Owners explicitly persist accepted commits; directory rows and their +checkpoint share one storage transaction. See +[data-model.md](data-model.md#replica-row-store) +for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline +resume behavior. The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an @@ -137,7 +154,7 @@ traffic. Workspace assignments stay on the workspace directory sequence. Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). - `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` -- `paseo daemon start/stop/restart/status/pair/set-password` +- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password` - `paseo terminal ls/create/capture/send-keys/kill` - `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` @@ -155,7 +172,7 @@ Communicates with the daemon via the same WebSocket protocol as the app. Enables remote access when the daemon is behind a firewall. -- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption +- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305 - The relay is zero-knowledge — it routes encrypted bytes and cannot read content - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Pairing via QR code transfers the daemon's public key to the client @@ -231,11 +248,22 @@ Server → Client: status message with payload { status: "server_info", serverId, hostname, version, capabilities?, features } ``` -There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +There is no dedicated welcome message; the server emits one initial `status.server_info` after +accepting hello. Modern clients negotiate `owned_subscriptions` against +`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data +demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client +correlates the initial reply by request ID, then routes updates by subscription ID. + +A logical Session can contain several physical sockets. Each socket owns its capabilities, +subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's +request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown. +The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges +its teardown; reconnect creates new server IDs for surviving client handles. See +[protocol compatibility](protocol-compatibility.md) for the legacy boundary. **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect. Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. @@ -289,7 +317,7 @@ messages or transfers. - WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required. - New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`. -- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities. Example: adding a new enum value @@ -300,6 +328,40 @@ Example: adding a new enum value // 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value" ``` +### Creation ownership + +Creation is owned by the daemon across socket lifetimes. A workspace request can include +its initial agent and prompt. Workspace readiness is published before provider startup; +the app keeps its existing disk-ready navigation and observes the remaining creation. +Callbacks do not advance the workflow. Resource reservations in acknowledgement are +identities, not ready workspace records. + +The creation journal (`server/creation/`) owns identity and execution for both legacy and +modern creation RPCs. Requesting progress never selects a different journal. Existing +agent receipts are imported at this boundary; message delivery receipts remain separate. +It keeps cumulative milestones and permanent IDs under an operation kind and idempotency +key. Reconnect subscribes to that key; retries +join active work or reuse committed stages. A persisted resource alone cannot prove +that a provider accepted its initial prompt. Interrupted side effects with no conclusive +receipt return an unknown outcome instead of being repeated. + +Agent registration commits creation. Rejecting the first prompt is a turn error on that +agent; reporting creation failure leaves an orphaned agent and invites another Create. +Receipt replay must not resend the prompt. + +`packages/client/src/creation/` owns capability selection and legacy orchestration. +Callers always pass the initial prompt to agent creation. On an older host, the client +adapts keyed creation to the legacy create/send sequence; it cannot continue that +sequence after the client process disappears. Explicitly requested IDs or receipts +that an old host cannot honor produce an unsupported error. Keep these adapters inside +the client package, as an exception to the default no-fallback feature policy. + +Creation executes through the existing Session capabilities. Connection-owned delivery +controls observation only: detaching a socket or cleaning up its Session does not cancel +accepted creation. Updates require an explicit subscription and go only to that socket; +reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their +existing response contract. + ## Agent lifecycle The lifecycle states are defined in `shared/agent-lifecycle.ts`: @@ -348,13 +410,13 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc | State | Key builder / store | Source | | ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- | -| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` | -| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` | +| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` | +| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` | | Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` | | File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` | | File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` | -`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys. +`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys. **Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism. @@ -410,12 +472,16 @@ $PASEO_HOME/ ├── config.json # Daemon config (mutable) ├── daemon-keypair.json # Daemon identity for relay/E2EE ├── push-tokens.json # Mobile push tokens -├── paseo.sock / paseo.pid # Local IPC socket and pidfile +├── paseo.pid # Supervisor identity and published bound endpoint └── daemon.log # Daemon trace logs (rotated) ``` +The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock. + +Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` -2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`). +2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform. 3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 734dda3c45..f760fafa67 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -12,8 +12,7 @@ It validates the compositor behavior that unit tests cannot see: by retrying until the frame appears; - both viewport `capturePage` and full-page CDP screenshots return real pixels from the permanent production parking state; -- guest background throttling can be disabled once at attach without per-capture - renderer coordination; +- parked guests remain capturable with Chromium background throttling enabled; - the real-Electron host-composer sentinel proves guest Enter cannot submit a focused host composer; - the automation group loads the compiled production keyboard boundary and guest @@ -44,6 +43,18 @@ Run the shared browser profile fixture with: PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop ``` +On macOS, test native titlebar dragging after scrolling with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop +``` + +This group needs Xcode Command Line Tools and Accessibility permission for the +terminal running it. It sends real mouse input to its test window and checks +window movement, including button clicks and a portal over the titlebar. Leave +the mouse idle during this group. It reads the app's production CSS and writes +screenshots and window bounds to the output directory. + The browser profile group runs two Electron processes in sequence. It verifies that each renderer-side `did-attach` identity maps to the correct main-process guest, that two live tabs share cookies and local storage through one persistent session, and that the data is @@ -73,11 +84,17 @@ The harness writes PNG evidence and `results.json` to: packages/desktop/capture-harness/out/ ``` -A passing run prints `PASS` lines for the production P1 attach-off parking state, +A passing run prints `PASS` lines for the production P1 default-throttling parking state, including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport is usually saved as 2560x1600. +The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey +verifies that a hidden window stops guest animation, captures fresh viewport pixels, +and resumes animation after restoring the window. Its artifacts include the screenshot +and animation measurements. Full-page content correctness remains separately tracked in +[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196). + ## Mechanism Electron captures copy from the guest web contents' compositor surface. A resident @@ -96,8 +113,11 @@ plane stays below the overlay plane regardless of body insertion order; menus ke layering inside `overlay-root`. Activating a presented browser also focuses its registered guest `WebContents` in main so macOS assigns keyboard first-responder ownership to the page. -There is no renderer prep/restore handshake. Main disables guest background throttling -once when the webview attaches, then screenshot capture uses the shared serialized queue, -invalidates before each attempt, and retries known first-frame failures within the -5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`; -full-page screenshots use the existing CDP path with layout metrics and screenshot clip. +There is no renderer prep/restore handshake or lifetime background-throttling override. +Screenshot capture temporarily enables frame production inside the shared serialized queue, +restores the previous throttling policy on success or failure, and retains a 5-second capture budget. +The browser tool takes one viewport frame through Electron's frame subscription and releases the +subscription on completion or cancellation. A resized resident guest can produce fresh pixels +while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or +retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout +metrics and screenshot clip. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index ec77d103b8..27f9585236 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -92,12 +92,20 @@ For testing rules, see [testing.md](testing.md). - Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects. - Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update. - Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed. -- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work. +- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering. - Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second. - Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary. - Use stable ids for `key`, never array index for reorderable/filterable lists. - Context for stable values (theme, auth). Store with selectors for state that changes. +### Retained panel measurements + +Inactive retained panels use `display: none`, so DOM and layout APIs report zero width and height. A non-positive measurement means the surface is not rendered; it is never a valid compact layout. + +- Suspend geometry-derived updates while either measured dimension is non-positive. Preserve the last valid state until the surface reports positive geometry again. +- Enforce the rule at the measurement boundary shared by every trigger. Guarding only an `onLayout` or `ResizeObserver` callback still lets effects, content changes, and imperative remeasurement publish hidden geometry. +- Test the hide-and-reveal path through a real retained surface. Settled assertions do not catch stale geometry painted during the first returned frames. + ## Naming - Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 924d1bc066..efc5fdf24f 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds: The limit applies independently to every provider refresh and covers availability plus the entire catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent. +## Provider options + +Set provider defaults in `agents.providers..options`. At agent creation, use +`providerOptions` on the wire or `config.options` in the SDK. Both are opaque +`Record` values: the provider owns validation and application. +Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error. +Providers without option handling ignore the record. + +The daemon deep-merges the agent's options over the provider defaults at each +session launch or resume. Plain objects merge recursively; arrays, scalars, and +`null` replace the base value. Only the agent's own options are persisted, so edits +to provider defaults apply on the next launch or resume. A profile inherits its +base provider's options unless it sets its own options record. + +```json +{ + "agents": { + "providers": { + "codex": { + "options": { "sandbox_mode": "read-only", "approval_policy": "never" } + } + } + } +} +``` + +See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for +per-agent examples and provider-specific keys. + --- ## Table of Contents @@ -391,8 +420,9 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` "XDG_CONFIG_HOME": "~/.config/omp-work", "XDG_STATE_HOME": "~/.local/state/omp-work" }, - "params": { + "options": { "sessionDir": "~/.local/state/omp-work/omp/agent/sessions", + "rpcTimeoutMs": 60000, "smolModel": "openai/gpt-5-mini", "slowModel": "anthropic/claude-opus-4-1", "planModel": "openai/o3" @@ -403,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` } ``` -`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. +`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines. For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory: @@ -415,8 +445,9 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere "extends": "pi", "label": "My Pi Fork", "command": ["my-pi-fork"], - "params": { - "sessionDir": "~/.my-pi-fork/sessions" + "options": { + "sessionDir": "~/.my-pi-fork/sessions", + "rpcTimeoutMs": 60000 } } } @@ -424,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere } ``` -This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. +This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. --- @@ -482,7 +513,7 @@ Required fields for ACP providers: - `label` - `command` — the command to spawn the agent process (must support ACP over stdio) -Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: +Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`: ```json { @@ -492,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog "extends": "acp", "label": "My Agent", "command": ["my-agent", "acp"], - "params": { + "options": { "supportsMcpServers": false } } @@ -501,25 +532,25 @@ Paseo tools such as subagent creation come from the shared internal tool catalog } ``` -ACP agents execute filesystem and terminal operations in their own environment -by default. To let a compliant agent delegate those operations to Paseo instead, -enable the corresponding client capabilities: +ACP agents execute filesystem operations in their own environment by default, +while terminal operations run through Paseo on the host. To customize which +operations Paseo handles, configure client capabilities in provider options: ```json { "agents": { "providers": { - "local-agent": { + "container-agent": { "extends": "acp", - "label": "Local Agent", - "command": ["local-agent", "acp"], - "params": { + "label": "Container Agent", + "command": ["container-agent", "acp"], + "options": { "clientCapabilities": { "fs": { - "readTextFile": true, - "writeTextFile": true + "readTextFile": false, + "writeTextFile": false }, - "terminal": true + "terminal": false } } } @@ -528,9 +559,11 @@ enable the corresponding client capabilities: } ``` -Only enable capabilities Paseo should execute. When the agent and Paseo run in -different environments, configure equivalent absolute workspace paths before -delegating filesystem or terminal operations to Paseo. +When an agent runs in a container or remote environment that manages its own +terminal, set `terminal: false` to keep command execution inside the agent +container. When delegating filesystem operations to Paseo (`fs.readTextFile: true` +or `fs.writeTextFile: true`), ensure the agent and Paseo share equivalent +absolute workspace paths. ### Generic ACP diagnostics @@ -680,7 +713,7 @@ Every entry under `agents.providers` accepts these fields: | `description` | `string` | No | Short description shown in the UI | | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | -| `params` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | +| `options` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | | `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | | `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | @@ -710,7 +743,7 @@ Each entry in the `models` array: ### Claude settings.json model discovery -The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. +The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. diff --git a/docs/data-model.md b/docs/data-model.md index 4da3abb251..25a214b11c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -48,6 +48,7 @@ $PASEO_HOME/ ├── server-id # Stable daemon identifier (plain text, "srv_") ├── daemon-keypair.json # E2EE keypair for relay (mode 0600) ├── paseo.pid # Daemon PID lock file +├── local-credential # Per-run local client credential (mode 0600) ├── daemon.log # Default log file (path configurable) ├── agents/ │ └── {sanitized-cwd}/ @@ -63,6 +64,9 @@ $PASEO_HOME/ ├── runtime/ │ └── managed-processes/ │ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap +├── plugins/ +│ ├── sources.json # Managed kind and Git acquisition remote +│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree └── push-tokens.json # Expo push notification tokens ``` @@ -178,8 +182,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId` Single file, validated with `PersistedConfigSchema`. `agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means -`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed -skill directories and keeps config plus filesystem convergence behind one serialized owner. +`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared +`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind +one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from +Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files. `paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution, classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe @@ -251,6 +257,14 @@ snapshot so a mixed edit can apply its live subset and still name the paths that All fields are optional with sensible defaults. +Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled +state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition +remote. Installed revision, package/subdirectory identity and ownership root come from retained +artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership) +explains their authority. An update prepares a new directory before replacing the active path and +removing the previous version. Old record fields are accepted at the store boundary and ignored; +there is no startup migration or persistent update policy. + ### Profile lists `terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the @@ -266,6 +280,45 @@ rather than storing something it cannot describe. That is why the client gates t UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an older daemon. +### Agent provider Paseo tools + +`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are +`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal +configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`. + +Each entry may include a Paseo-tool policy: + +```json +{ + "agents": { + "providers": { + "my-claude": { + "extends": "claude", + "label": "My Claude", + "paseoTools": { + "enabled": true, + "disabledTools": ["browser_evaluate"] + } + } + } + } +} +``` + +Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are +allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool +IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool. +Browser tools also require `daemon.browserTools.enabled` and a connected browser host. +This policy controls the catalog presented to an agent. It is not an authorization boundary for +agents that can access the host through a shell. + +`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives +Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a +session is created, resumed, imported, or reloaded, so configuration changes affect the next +session rather than an already-running one. + +`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. + ### Git process limits Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per @@ -430,25 +483,26 @@ workspace together with its owning project. Array of workspace records. A workspace is a specific working directory within a project. -| Field | Type | Description | -| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | -| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | -| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | -| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | -| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | -| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | -| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | -| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | -| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | -| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | -| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | -| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | -| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. | -| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | +| Field | Type | Description | +| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | +| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | +| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | +| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | +| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | +| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | +| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | +| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. | +| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | +| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | +| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | +| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. | +| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | +| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. | > **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. @@ -506,6 +560,7 @@ These small files are not validated as full Zod schemas but are persisted under | `server-id` | Plain text, e.g. `srv_` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. | | `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. | | `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. | +| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. | | `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. | --- @@ -521,6 +576,26 @@ Right-sidebar client state splits on whether it is determined by the directory o - **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores. - **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path. +### Replica row store + +The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the +compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and +`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint +uses the singleton id. + +The store is a typed persistence boundary. It returns values to directory and timeline owners and +accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested +host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a +directory scan. One invalid row is deleted and returned as a miss without affecting other rows. +An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a +later launch cannot accept a checkpoint for a partial baseline. Directory changes and their +checkpoint are also applied in one transaction. + +The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget +bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or +before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain +source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision. + ### Draft Store **AsyncStorage key:** `paseo-drafts` (version 2) diff --git a/docs/design.md b/docs/design.md index c1e6953625..e21270c85d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. -A pressable styled to look like a button is wrong; the button is ` + + + + ); +} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 5083d63916..7da36742ae 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -14,17 +14,16 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { formatTimeAgo } from "@/utils/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { Archive, ChevronRight } from "lucide-react-native"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { HighlightedText } from "@/components/ui/highlighted-text"; import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge"; -import type { AgentSearchMatch } from "@getpaseo/protocol/messages"; -import type { MatchRange } from "@getpaseo/protocol/search/text-match"; +import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; interface AgentListProps { agents: AggregatedAgent[]; @@ -36,18 +35,7 @@ interface AgentListProps { listFooterComponent?: ReactElement | null; showAttentionIndicator?: boolean; showHostColumn?: boolean; - /** - * Where a search matched each row, keyed by `serverId:agentId`. Rows mark the - * spans so the list can explain why a result is in it — the subsequence and - * typo tiers match characters the eye would not find on its own. - */ - searchMatchesByAgentKey?: Record; - /** - * Renders one flat list in the given order instead of grouping by day. Day - * headings claim the list is chronological, which is a lie once the caller - * has ordered it by something else — relevance, for instance. - */ - flat?: boolean; + search?: string; } type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older"; @@ -122,39 +110,6 @@ function SessionBadge({ return ; } -function WorkspaceTitlePrefix({ - visible, - workspaceName, - ranges, - testID, - iconSize, - color, -}: { - visible: boolean; - workspaceName: string; - ranges?: readonly MatchRange[]; - testID: string; - iconSize: number; - color: string; -}) { - if (!visible) { - return null; - } - - return ( - <> - - - - ); -} - function SessionRowBadges({ agent, archivedIcon, @@ -207,7 +162,7 @@ function SessionRowTrailingAttention({ function SessionRow({ agent, - searchMatches, + search, isMobile, selectedAgentId, showAttentionIndicator, @@ -216,7 +171,7 @@ function SessionRow({ onLongPress, }: { agent: AggregatedAgent; - searchMatches?: readonly AgentSearchMatch[]; + search?: string; isMobile: boolean; selectedAgentId?: string; showAttentionIndicator: boolean; @@ -226,18 +181,21 @@ function SessionRow({ }) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; const projectName = agent.projectPlacement?.projectName ?? ""; const branch = agent.projectPlacement?.checkout.currentBranch ?? ""; const workspaceName = agent.projectPlacement?.workspaceName ?? ""; - const ProviderIcon = getProviderIcon(agent.provider); + const ProviderIcon = useProviderIcon(agent.provider, agent.serverId); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; - const rangesFor = useCallback( - (field: AgentSearchMatch["field"]) => - searchMatches?.find((match) => match.field === field)?.ranges, - [searchMatches], + const ranges = useMemo( + () => ({ + workspace: findHighlightRanges(search ?? "", workspaceName), + title: findHighlightRanges(search ?? "", agent.title ?? ""), + branch: findHighlightRanges(search ?? "", branch), + project: findHighlightRanges(search ?? "", projectName), + }), + [search, workspaceName, agent.title, branch, projectName], ); const pressableStyle = useCallback( @@ -253,11 +211,6 @@ function SessionRow({ const handlePress = useCallback(() => onPress(agent), [onPress, agent]); const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]); - const sessionTitleStyle = useMemo( - () => [styles.sessionTitle, isSelected && styles.sessionTitleHighlighted], - [isSelected], - ); - const archivedIcon = useMemo( () => , [theme.fontSize.sm, theme.colors.foregroundMuted], @@ -265,32 +218,44 @@ function SessionRow({ const showDesktopAttention = !isMobile && showAttentionIndicator && Boolean(agent.requiresAttention); + const agentTitle = ( + + + + + + + ); + return ( - - - - + {!isMobile ? ( + <> + + {agentTitle} + + ) : null} + {isMobile ? agentTitle : null} {isMobile ? ( · · - - · - {timeAgo} + {showHostColumn && agent.serverLabel ? ( <> · @@ -340,7 +298,7 @@ function SessionRow({ - - {timeAgo} - + ) : null} + {label} + + ); +} + export function AgentList({ agents, isRefreshing = false, @@ -380,8 +345,7 @@ export function AgentList({ listFooterComponent, showAttentionIndicator = true, showHostColumn = false, - searchMatchesByAgentKey, - flat = false, + search, }: AgentListProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -449,14 +413,6 @@ export function AgentList({ }, [actionAgent, actionClient, archiveAgent]); const flatItems = useMemo((): FlatListItem[] => { - if (flat) { - return agents.map((agent) => ({ - type: "agent" as const, - key: `${agent.serverId}:${agent.id}`, - agent, - })); - } - const buckets = new Map(); for (const agent of agents) { const section = deriveDateSectionKey(agent.lastActivityAt); @@ -477,7 +433,7 @@ export function AgentList({ } } return result; - }, [agents, flat]); + }, [agents]); const renderItem: ListRenderItem = useCallback( ({ item }) => { @@ -491,7 +447,7 @@ export function AgentList({ return ( ({ gap: theme.spacing[2], overflow: "hidden", }, - providerIconWrap: { - width: theme.iconSize.md, + agentTitleRow: { + flexDirection: "row", alignItems: "center", - justifyContent: "center", + gap: theme.spacing[2], + flexShrink: 1, + minWidth: 0, + }, + providerIconWrap: { + flexShrink: 0, }, workspaceTitleText: { - flexShrink: 0, - maxWidth: 220, + flexShrink: { xs: 1, md: 0 }, + maxWidth: { xs: "100%", md: 320 }, fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, + color: theme.colors.foreground, }, rowMetaRow: { flexDirection: "row", @@ -682,11 +643,7 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, fontSize: theme.fontSize.base, fontWeight: "400", - color: theme.colors.foreground, - opacity: 0.86, - }, - sessionTitleHighlighted: { - opacity: 1, + color: theme.colors.foregroundMuted, }, sessionMetaText: { maxWidth: "100%", diff --git a/packages/app/src/components/appearance-style-boundary.test.ts b/packages/app/src/components/appearance-style-boundary.test.ts index a92278cce5..da6fb85c45 100644 --- a/packages/app/src/components/appearance-style-boundary.test.ts +++ b/packages/app/src/components/appearance-style-boundary.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { darkTheme } from "@/styles/theme"; import { appearanceStyleBoundaryKey } from "./appearance-style-boundary"; @@ -14,3 +16,58 @@ describe("appearanceStyleBoundaryKey", () => { ); }); }); + +// This is a placement contract against the production JSX, not a simulated native mount. +// Native Gesture Handler/Fabric lifetime behavior is verified on the release app. +describe("retained native gesture host appearance placement", () => { + it.each([ + ["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]], + [ + "./compact-explorer-sidebar-host.tsx", + ["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"], + ], + ] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => { + const source = ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let hostsFound = 0; + let webKeysFound = 0; + function visit(node: ts.Node) { + let name: string | undefined; + if (ts.isIdentifier(node)) name = node.text; + if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); + if (name && hosts.some((host) => host === name)) { + hostsFound++; + let webOnly = false; + let keyed = false; + for ( + let child = node, parent = node.parent; + parent; + child = parent, parent = parent.parent + ) { + if ( + ts.isIfStatement(parent) && + parent.expression.getText(source) === "isWeb" && + parent.thenStatement === child + ) + webOnly = true; + if ( + ts.isJsxElement(parent) && + parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" + ) + keyed = true; + } + if (keyed && webOnly) webKeysFound++; + expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false); + } + ts.forEachChild(node, visit); + } + visit(source); + expect(hostsFound).toBeGreaterThan(0); + expect(webKeysFound).toBeGreaterThan(0); + }); +}); diff --git a/packages/app/src/components/appearance-style-boundary.tsx b/packages/app/src/components/appearance-style-boundary.tsx index f64b3e553f..e4f2a61f73 100644 --- a/packages/app/src/components/appearance-style-boundary.tsx +++ b/packages/app/src/components/appearance-style-boundary.tsx @@ -7,6 +7,15 @@ interface AppearanceStyleBoundaryProps { children: ReactNode; } +// Remounts its children when an appearance token changes. Memoized and parsed content bakes +// tokens in, and on web numeric tokens (font sizes, line heights) are baked into generated +// classes that only a re-render refreshes. Native tracked styles update in place. +// Keep native gesture hosts outside these keys too: shared gesture refs can outlive detached +// dependent views until passive cleanup. It must sit below every native navigator: remounting +// a navigator while settings hydrate +// detaches its screen container inside a FragmentManager transaction and crashes Android. +// `ThemedStack` places one per screen; the app shell places them around the chrome outside the +// navigators. function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) { return {children}; } diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 8982f6e06a..b5f6a3ea54 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -1,12 +1,10 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { View, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import Animated from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { FOOTER_HEIGHT } from "@/constants/layout"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { KeyboardTranslateView } from "@/keyboard/shift"; import { Button } from "@/components/ui/button"; import type { Theme } from "@/styles/theme"; import { toErrorMessage } from "@/utils/error-messages"; @@ -18,19 +16,11 @@ interface ArchivedAgentCalloutProps { export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) { const { t } = useTranslation(); - const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const [isUnarchiving, setIsUnarchiving] = useState(false); const [unarchiveError, setUnarchiveError] = useState(null); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" }); - - const containerStyle = useMemo( - () => [styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle], - [insets.bottom, keyboardAnimatedStyle], - ); - const handleUnarchive = useCallback(async () => { if (!client || !isConnected || isUnarchiving) return; setIsUnarchiving(true); @@ -44,7 +34,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout }, [client, isConnected, isUnarchiving, agentId]); return ( - + @@ -67,7 +57,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - + ); } @@ -87,7 +77,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, inputAreaContent: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, }, callout: { flexDirection: "row", diff --git a/packages/app/src/components/assistant-message-render-limit.test.ts b/packages/app/src/components/assistant-message-render-limit.test.ts new file mode 100644 index 0000000000..52a7799633 --- /dev/null +++ b/packages/app/src/components/assistant-message-render-limit.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT, + capAssistantMessageForRender, + getUtf8ByteLength, +} from "./assistant-message-render-limit"; + +describe("assistant message render limit", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("leaves messages at the limit unchanged", () => { + const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT); + + expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false }); + }); + + it("caps oversized messages before markdown rendering", () => { + const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1); + + expect(capAssistantMessageForRender(message)).toEqual({ + text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT), + capped: true, + }); + }); + + it.each(["😀", "é", "👨‍👩‍👧‍👦", "👍🏽", "❤️", "🇺🇸", "가", "कः"])( + "does not split %s at the render limit", + (cluster) => { + for (let offset = 1; offset < cluster.length; offset++) { + const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - offset); + expect(capAssistantMessageForRender(`${prefix}${cluster}tail`)).toEqual({ + text: prefix, + capped: true, + }); + } + }, + ); + + it("counts the complete message size in UTF-8 bytes", () => { + expect(getUtf8ByteLength("aé😀")).toBe(7); + }); + + it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => { + vi.resetModules(); + vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined }); + const { capAssistantMessageForRender: capWithoutSegmenter } = + await import("./assistant-message-render-limit"); + const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1); + + expect(capWithoutSegmenter(message)).toEqual({ + text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT), + capped: true, + }); + }); +}); diff --git a/packages/app/src/components/assistant-message-render-limit.ts b/packages/app/src/components/assistant-message-render-limit.ts new file mode 100644 index 0000000000..0c37270cb5 --- /dev/null +++ b/packages/app/src/components/assistant-message-render-limit.ts @@ -0,0 +1,55 @@ +const graphemeSegmenter = + typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + +export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000; + +export interface CappedAssistantMessage { + text: string; + capped: boolean; +} + +export function getUtf8ByteLength(message: string): number { + let bytes = 0; + + for (let index = 0; index < message.length; index += 1) { + const codeUnit = message.charCodeAt(index); + if (codeUnit < 0x80) { + bytes += 1; + } else if (codeUnit < 0x800) { + bytes += 2; + } else if ( + codeUnit >= 0xd800 && + codeUnit <= 0xdbff && + index + 1 < message.length && + message.charCodeAt(index + 1) >= 0xdc00 && + message.charCodeAt(index + 1) <= 0xdfff + ) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + } + + return bytes; +} + +export function capAssistantMessageForRender(message: string): CappedAssistantMessage { + if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) { + return { text: message, capped: false }; + } + + let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT; + if (graphemeSegmenter) { + end = graphemeSegmenter.segment(message).containing(end)!.index; + } else { + const finalCodeUnit = message.charCodeAt(end - 1); + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { + end -= 1; + } + } + + return { text: message.slice(0, end), capped: true }; +} diff --git a/packages/app/src/components/assistant-turn-footer-label.test.ts b/packages/app/src/components/assistant-turn-footer-label.test.ts new file mode 100644 index 0000000000..7e3e918df8 --- /dev/null +++ b/packages/app/src/components/assistant-turn-footer-label.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { i18n } from "@/i18n/i18next"; +import { getTurnDurationLabel } from "./assistant-turn-footer-label"; + +describe("getTurnDurationLabel", () => { + it("says how long the turn worked", () => { + expect(getTurnDurationLabel(14_000, i18n.t)).toBe("Worked for 14s"); + }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + expect(getTurnDurationLabel(14_000, i18n.t)).toBe("A travaillé pendant 14s"); + } finally { + await i18n.changeLanguage("en"); + } + }); +}); diff --git a/packages/app/src/components/assistant-turn-footer-label.ts b/packages/app/src/components/assistant-turn-footer-label.ts new file mode 100644 index 0000000000..2e2a088f0c --- /dev/null +++ b/packages/app/src/components/assistant-turn-footer-label.ts @@ -0,0 +1,6 @@ +import type { TFunction } from "i18next"; +import { formatDuration } from "@/utils/time"; + +export function getTurnDurationLabel(durationMs: number, t: TFunction): string { + return t("message.turnFooter.workedFor", { duration: formatDuration(durationMs) }); +} diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index 8948cf9db7..ef5aa1833a 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client"; import { JSDOM } from "jsdom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AttachmentMetadata } from "@/attachments/types"; -import { AttachmentLightbox } from "./attachment-lightbox"; +import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox"; const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => { const hoistedTheme = { @@ -42,6 +42,11 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => { ), }; }); +const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata }; +const assistantImageSource: ImageLightboxSource = { + type: "uri", + uri: "blob:assistant-image", +}; vi.mock("react-native-unistyles", () => ({ StyleSheet: { @@ -70,6 +75,11 @@ vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), })); +vi.mock("react-native-gesture-handler", () => ({ + GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) => + React.createElement("div", { "data-testid": "gesture-handler-root" }, children), +})); + vi.mock("lucide-react-native", () => { const createIcon = (name: string) => (props: Record) => React.createElement("span", { ...props, "data-icon": name }); @@ -78,16 +88,34 @@ vi.mock("lucide-react-native", () => { }; }); -vi.mock("expo-image", () => ({ - Image: (props: Record) => { - const source = props.source as { uri?: string } | string | undefined; - const uri = typeof source === "string" ? source : source?.uri; - return React.createElement("div", { - "data-testid": props.testID, - "data-source": uri, - "data-style": JSON.stringify(props.style ?? null), - role: "img", - }); +vi.mock("@/components/zoomable-viewport/image", () => ({ + ZoomableImage: (props: Record) => { + const actions = props.actions as Array<{ + label: string; + onPress: () => void; + testID?: string; + }>; + return React.createElement( + "div", + { + "data-testid": `${String(props.testID)}-image`, + "data-source": props.uri, + role: "img", + }, + actions.map((action) => + React.createElement( + "button", + { + "aria-label": action.label, + "data-testid": action.testID, + key: action.label, + onClick: action.onPress, + type: "button", + }, + action.label, + ), + ), + ); }, })); @@ -108,6 +136,8 @@ let container: HTMLElement | null = null; beforeEach(() => { const dom = new JSDOM(""); + dom.window.requestAnimationFrame = vi.fn(() => 1); + dom.window.cancelAnimationFrame = vi.fn(); vi.stubGlobal("React", React); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", dom.window); @@ -150,42 +180,32 @@ function queryByTestId(testID: string): HTMLElement | null { } describe("AttachmentLightbox", () => { - it("renders nothing when metadata is null", () => { - render(); + it("renders nothing when the source is null", () => { + render(); expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull(); expect(queryByTestId("attachment-lightbox-image")).toBeNull(); }); it("renders the image when metadata is provided", () => { - render(); + render(); const image = queryByTestId("attachment-lightbox-image"); expect(image).not.toBeNull(); expect(image?.getAttribute("data-source")).toBe("blob:preview"); }); - it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => { - render(); + it("renders a direct timeline image without resolving attachment storage", () => { + render(); const image = queryByTestId("attachment-lightbox-image"); - const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as { - position?: string; - top?: number; - left?: number; - right?: number; - bottom?: number; - } | null; - expect(style?.position).toBe("absolute"); - expect(style?.top).toBe(0); - expect(style?.left).toBe(0); - expect(style?.right).toBe(0); - expect(style?.bottom).toBe(0); + expect(image?.getAttribute("data-source")).toBe("blob:assistant-image"); + expect(useAttachmentPreviewUrlMock).toHaveBeenLastCalledWith(null); }); it("calls onClose when the backdrop is pressed", () => { const onClose = vi.fn(); - render(); + render(); const backdrop = queryByTestId("attachment-lightbox-backdrop"); expect(backdrop).not.toBeNull(); @@ -196,7 +216,7 @@ describe("AttachmentLightbox", () => { it("calls onClose when the close button is pressed", () => { const onClose = vi.fn(); - render(); + render(); const closeButton = document.querySelector( '[aria-label="Close image"][data-testid="attachment-lightbox-close"]', @@ -209,7 +229,7 @@ describe("AttachmentLightbox", () => { it("shows error text when the preview URL resolves to null", () => { useAttachmentPreviewUrlMock.mockReturnValue(null); - render(); + render(); expect(queryByTestId("attachment-lightbox-image")).toBeNull(); expect(document.body.textContent ?? "").toContain("Couldn't load image"); @@ -217,7 +237,7 @@ describe("AttachmentLightbox", () => { it("closes on Escape key on web", () => { const onClose = vi.fn(); - render(); + render(); act(() => { window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" })); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index d916bddd23..3cf2c5203f 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -1,63 +1,88 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Modal, Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Image as ExpoImage } from "expo-image"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; -import { isWeb } from "@/constants/platform"; -import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; +import { isNative, isWeb } from "@/constants/platform"; +import { SPACING } from "@/styles/theme"; +import { WindowChromeRootRegion } from "@/utils/desktop-window"; +import { ZoomableImage } from "@/components/zoomable-viewport/image"; +import type { ViewportSize } from "@/components/zoomable-viewport/geometry"; +import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; + +export type ImageLightboxSource = + | { type: "attachment"; metadata: AttachmentMetadata } + | { type: "uri"; uri: string; contentSize?: ViewportSize }; interface AttachmentLightboxProps { - metadata: AttachmentMetadata | null; + source: ImageLightboxSource | null; onClose: () => void; } -export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) { - const { theme } = useUnistyles(); +const ModalRoot = isNative ? GestureHandlerRootView : View; +const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 }; + +export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const url = useAttachmentPreviewUrl(metadata); + const metadata = source?.type === "attachment" ? source.metadata : null; + const attachmentUrl = useAttachmentPreviewUrl(metadata); + const url = source?.type === "uri" ? source.uri : attachmentUrl; + const contentSize = source?.type === "uri" ? source.contentSize : undefined; const [errored, setErrored] = useState(false); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null); useEffect(() => { setErrored(false); - }, [metadata?.id]); + }, [metadata?.id, url]); - useEffect(() => { - if (!isWeb || !metadata) return; - function handleKeydown(event: KeyboardEvent) { - if (event.key === "Escape") { - onClose(); - } - } - window.addEventListener("keydown", handleKeydown); - return () => { - window.removeEventListener("keydown", handleKeydown); - }; - }, [metadata, onClose]); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + event.stopPropagation(); + onClose(); + return true; + }, + [onClose], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && source !== null, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); - const closeButtonRowStyle = useMemo( + const contentLayerStyle = useMemo( () => [ - styles.closeButtonRow, + styles.contentLayer, { - top: insets.top + theme.spacing[3], + paddingTop: insets.top, + paddingRight: insets.right, + paddingBottom: insets.bottom, + paddingLeft: insets.left, }, ], - [insets.top, theme.spacing], + [insets.bottom, insets.left, insets.right, insets.top], ); - const closeButtonStyle = useMemo( - () => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }], - [insets.right, theme.spacing], + const actions = useMemo( + () => [ + { + icon: X, + label: t("message.attachments.closeImage"), + onPress: onClose, + testID: "attachment-lightbox-close", + }, + ], + [onClose, t], ); const handleImageError = useCallback(() => setErrored(true), []); - const noopPress = useCallback(() => {}, []); - const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]); - if (!metadata) { + if (!source) { return null; } @@ -65,61 +90,47 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp return ( - - - - - - {hasError ? ( - {t("message.attachments.imageLoadFailed")} - ) : ( - - + + + + + + {hasError ? ( + {t("message.attachments.imageLoadFailed")} + ) : ( + - - )} + )} + - - - - - - - + + ); } -const imageFillStyle = { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, -} as const; - const styles = StyleSheet.create((theme) => ({ root: { flex: 1, + minHeight: 0, + minWidth: 0, }, backdrop: { position: "absolute", @@ -135,42 +146,19 @@ const styles = StyleSheet.create((theme) => ({ left: 0, right: 0, bottom: 0, - pointerEvents: "box-none", - }, - closeButtonRow: { - position: "absolute", - left: 0, - right: 0, - alignItems: "flex-end", - pointerEvents: "box-none", }, imageArea: { flex: 1, alignItems: "center", justifyContent: "center", - padding: theme.spacing[4], - pointerEvents: "box-none", }, - imagePressable: { + imageViewport: { flex: 1, width: "100%", alignSelf: "center", - maxWidth: 960, - maxHeight: 640, }, errorText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, - closeButton: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - zIndex: 1, - }, })); diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index c1663cbdcd..737035d540 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, GitBranch } from "lucide-react-native"; +import { GitBranch } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; @@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; +import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger"; interface BranchSwitcherProps { currentBranchName: string | null; @@ -23,9 +24,7 @@ interface BranchSwitcherProps { const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); - const ThemedGitBranch = withUnistyles(GitBranch); -const ThemedChevronDown = withUnistyles(ChevronDown); export function BranchSwitcher({ currentBranchName, @@ -56,14 +55,6 @@ export function BranchSwitcher({ const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]); - const triggerStyle = useCallback( - ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.trigger, - (Boolean(hovered) || pressed) && styles.triggerHovered, - ], - [], - ); - const branchLeadingSlot = useMemo( () => , [], @@ -90,21 +81,16 @@ export function BranchSwitcher({ - - - - {currentBranchName} - - - + /> {t("branchSwitcher.triggerTooltip")} @@ -136,25 +122,6 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, minWidth: 0, }, - trigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - minWidth: 0, - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1], - borderRadius: theme.borderRadius.md, - flexShrink: 1, - }, - triggerHovered: { - backgroundColor: theme.colors.surface1, - }, - branchLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontWeight: theme.fontWeight.normal, - flexShrink: 1, - }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 5639c604aa..8473760c92 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -8,6 +8,8 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; import { ModelBrowser, ModelProviderGlyph, useModelBrowser } from "@/components/model-browser"; +import { resolveModelBrowserScrolling } from "@/components/model-browser-view"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { ICON_SIZE, type Theme } from "@/styles/theme"; @@ -87,6 +89,8 @@ export function CombinedModelSelector({ toolbar, }: CombinedModelSelectorProps) { const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const modelBrowserScrolling = resolveModelBrowserScrolling({ isNative, isCompact }); const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [isContentReady, setIsContentReady] = useState(isWeb); @@ -198,7 +202,7 @@ export function CombinedModelSelector({ onEditProfile={onEditProfile ? handleEditProfile : undefined} onRetryProvider={onRetryProvider} isRetryingProvider={isRetryingProvider} - scrolling={isWeb ? "independent" : "sheet"} + scrolling={modelBrowserScrolling} /> ) : ( @@ -251,6 +255,7 @@ export function CombinedModelSelector({ diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index d001eba69e..2dae4b9042 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -1,21 +1,27 @@ -import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react"; -import { View } from "react-native"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar"; +import { + CompactExplorerSidebar, + NativeExplorerSidebarDock, +} from "@/components/compact-explorer-sidebar"; import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; +import { usePanelStore } from "@/stores/panel-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { isWeb } from "@/constants/platform"; +import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache"; import { resolveCompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel, } from "@/components/compact-explorer-sidebar-host-state"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; interface CompactExplorerOpenGestureSurfaceProps { children: ReactNode; @@ -47,7 +53,7 @@ function useActiveCompactExplorerSidebarModel( ): CompactExplorerSidebarHostModel | null { const selection = useActiveWorkspaceSelection(); const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); - const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen); + const isExplorerActive = useIsMobilePanelActive("file-explorer"); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const client = useHostRuntimeClient(selection?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); @@ -63,41 +69,47 @@ function useActiveCompactExplorerSidebarModel( const resolvedModel = useMemo( () => resolveCompactExplorerSidebarHostModel({ - previous: isExplorerOpen ? retainedModelRef.current : null, + previous: isExplorerActive ? retainedModelRef.current : null, selection, workspace, isGit: checkoutQuery.data?.isGit ?? false, }), - [checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace], + [checkoutQuery.data?.isGit, isExplorerActive, selection, workspace], ); useEffect(() => { if (!selection) { retainedModelRef.current = null; - if (enabled && isExplorerOpen) { + if (enabled && isExplorerActive) { showMobileAgent(); } return; } - if (!isExplorerOpen) { + if (!isExplorerActive) { retainedModelRef.current = null; return; } if (resolvedModel) { retainedModelRef.current = resolvedModel; } - }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); + }, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]); - return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null; + return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null; } interface CompactExplorerSidebarHostProps { children: ReactNode; enabled: boolean; + presentation: "overlay" | "dock"; } -export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) { +export function CompactExplorerSidebarHost({ + children, + enabled, + presentation, +}: CompactExplorerSidebarHostProps) { const model = useActiveCompactExplorerSidebarModel(enabled); + const [containerWidth, setContainerWidth] = useState(0); const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const openTab = useWorkspaceLayoutStore((state) => state.openTab); @@ -127,14 +139,59 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore openWorkspaceFileFromExplorer({ filePath, persistenceKey: model.persistenceKey, + closeExplorerAfterOpen: presentation === "overlay", showMobileAgent, openWorkspaceTabInFocusedPane, focusWorkspaceTab, }); }, - [focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent], + [focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent], ); + const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { + const nextWidth = event.nativeEvent.layout.width; + setContainerWidth((current) => (current === nextWidth ? current : nextWidth)); + }, []); + + const explorer = + enabled && model ? ( + + {presentation === "dock" ? ( + + ) : ( + + )} + + ) : null; + + let themedExplorer = explorer; + if (isWeb) { + themedExplorer = {explorer}; + } + + if (presentation === "dock") { + return ( + + {children} + {themedExplorer} + + ); + } + return ( <> {children} - {enabled && model ? ( - - ) : null} + {themedExplorer} ); } @@ -160,4 +209,8 @@ const styles = { fill: { flex: 1, }, + row: { + flex: 1, + flexDirection: "row", + }, } as const; diff --git a/packages/app/src/components/compact-explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx index 788a379b8a..d52a9eac36 100644 --- a/packages/app/src/components/compact-explorer-sidebar.tsx +++ b/packages/app/src/components/compact-explorer-sidebar.tsx @@ -1,7 +1,10 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, Pressable } from "react-native"; +import { Gesture } from "react-native-gesture-handler"; +import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel"; @@ -11,17 +14,19 @@ import { type ExplorerTab, } from "@/stores/panel-store"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE, + useIsCompactFormFactor, } from "@/constants/layout"; import { ChangesSurface } from "@/git/diff-pane"; import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state"; import { FileExplorerPane } from "./file-explorer-pane"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy"; +import { useKeyboardShiftStyle } from "@/keyboard/shift"; +import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift"; import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel"; @@ -29,6 +34,21 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability"; import { PullRequestContent } from "@/panels/pull-request"; import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; +import { + SIDEBAR_RESIZE_ACTIVATION_OFFSET, + SIDEBAR_RESIZE_FAIL_OFFSET, +} from "@/components/sidebar-resize-handle-layout"; +import { + EXPLORER_TAB_RAIL_INSET, + explorerSidebarCloseButtonLayout, + resolveExplorerSidebarWidth, +} from "@/components/explorer-sidebar-layout"; +import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; +import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; + +const ThemedX = withUnistyles(X); function logExplorerSidebar(_event: string, _details: Record): void {} @@ -71,7 +91,7 @@ export function CompactExplorerSidebar({ }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const isOpen = usePanelStore(selectIsCompactFileExplorerOpen); + const isActive = useIsMobilePanelActive("file-explorer"); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ serverId, @@ -89,11 +109,11 @@ export function CompactExplorerSidebar({ (reason: string) => { logExplorerSidebar("handleClose", { reason, - isOpen, + isOpen: isActive, }); showMobileAgent(); }, - [isOpen, showMobileAgent], + [isActive, showMobileAgent], ); const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); @@ -117,7 +137,7 @@ export function CompactExplorerSidebar({ ); return ( - + @@ -139,6 +159,118 @@ export function CompactExplorerSidebar({ ); } +interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps { + persistenceKey: string; + containerWidth: number; +} + +export function NativeExplorerSidebarDock({ + serverId, + workspaceId, + workspaceRoot, + isGit, + onOpenFile, + persistenceKey, + containerWidth, +}: NativeExplorerSidebarDockProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const isOpen = usePanelStore(selectIsCompactFileExplorerOpen); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + const storedWidth = useWorkspaceLayoutStore( + (state) => state.explorerSidebarWidthByWorkspace[persistenceKey], + ); + const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); + const visibleWidth = resolveExplorerSidebarWidth({ + requestedWidth: storedWidth, + containerWidth, + }); + const resizeWidth = useSharedValue(visibleWidth); + const startWidthRef = useRef(visibleWidth); + const [resizePressed, setResizePressed] = useState(false); + const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ + serverId, + workspaceRoot, + isGit, + }); + + useEffect(() => { + resizeWidth.value = visibleWidth; + }, [resizeWidth, visibleWidth]); + + const showResizeGrip = useCallback(() => setResizePressed(true), []); + const hideResizeGrip = useCallback(() => setResizePressed(false), []); + const commitWidth = useCallback( + (width: number) => resizeExplorerSidebar(persistenceKey, width), + [persistenceKey, resizeExplorerSidebar], + ); + const resizeGesture = useMemo( + () => + Gesture.Pan() + .enabled(true) + .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) + .onBegin(() => scheduleOnRN(showResizeGrip)) + .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) + .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) + .onStart((event) => { + startWidthRef.current = visibleWidth + event.translationX; + resizeWidth.value = visibleWidth; + }) + .onUpdate((event) => { + resizeWidth.value = resolveExplorerSidebarWidth({ + requestedWidth: startWidthRef.current - event.translationX, + containerWidth, + }); + }) + .onEnd(() => runOnJS(commitWidth)(resizeWidth.value)) + .onFinalize(() => scheduleOnRN(hideResizeGrip)), + [commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth], + ); + const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value })); + const dockStyle = useMemo( + () => [ + styles.nativeDock, + { + display: isOpen ? ("flex" as const) : ("none" as const), + paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, + backgroundColor: theme.colors.surfaceSidebar, + }, + animatedWidthStyle, + ], + [animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar], + ); + const dockContentStyle = useMemo( + () => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }], + [theme.colors.border], + ); + + return ( + + + + + + + + + ); +} + interface ExplorerTabButtonProps { tab: ExplorerTab; active: boolean; @@ -156,8 +288,12 @@ function ExplorerTabButton({ testID, children, }: ExplorerTabButtonProps) { + const isCompact = useIsCompactFormFactor(); const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]); - const tabStyle = useMemo(() => [styles.tab, active && styles.tabActive], [active]); + const tabStyle = useMemo( + () => [styles.tab(isCompact), active && styles.tabActive], + [active, isCompact], + ); const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]); return ( @@ -192,6 +328,17 @@ function ExplorerSidebarContent({ }: SidebarContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact); + const closeButtonStyle = useMemo( + () => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }), + [closeButtonLayout.size], + ); + // The close glyph shares the trailing rail with the toolbar rows below it. + const headerRightSectionStyle = useMemo( + () => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }], + [closeButtonLayout.trailingPadding], + ); const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({ serverId, cwd: workspaceRoot, @@ -218,19 +365,14 @@ function ExplorerSidebarContent({ return ( {/* Header with tabs and close button */} - + - + {isGit && ( @@ -238,7 +380,7 @@ function ExplorerSidebarContent({ @@ -260,24 +402,18 @@ function ExplorerSidebarContent({ )} - - + - {({ hovered, pressed }) => ( - - )} - + + @@ -335,12 +471,10 @@ function ChangedFilesPane({ ); return ( ({ + nativeDock: { + position: "relative", + height: "100%", + minHeight: 0, + overflow: "hidden", + }, + nativeDockContent: { + position: "relative", + flex: 1, + minHeight: 0, + borderLeftWidth: 1, + }, sidebarContent: { flex: 1, minHeight: 0, @@ -387,18 +533,21 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, - tabsContainer: { + tabsContainer: (isCompact: boolean) => ({ flexDirection: "row", gap: theme.spacing[1], - }, - tab: { + // With the tab's own horizontal padding this puts the label on the pane's + // leading rail, the same way the desktop tab rail does. + paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2], + }), + tab: (isCompact: boolean) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3], borderRadius: theme.borderRadius.md, - }, + }), tabActive: { backgroundColor: theme.colors.surfaceSidebarHover, }, @@ -418,10 +567,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - closeButton: { - padding: theme.spacing[2], - borderRadius: theme.borderRadius.md, - }, contentArea: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/context-window-details.tsx b/packages/app/src/components/context-window-details.tsx new file mode 100644 index 0000000000..3221539cec --- /dev/null +++ b/packages/app/src/components/context-window-details.tsx @@ -0,0 +1,82 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { AgentUsage } from "@/usage"; +import { formatTokenCount } from "./context-window-meter.utils"; + +interface ContextWindowDetailsProps { + serverId: string; + agentId: string; + context: { + percentage: number; + usedTokens: number; + maxTokens: number; + } | null; + sessionCost: string | null; + /** The hover card and tooltip title themselves; the sheet's header carries the title instead. */ + showTitle: boolean; + /** Whether the usage cards have a Refresh button; without one they show their freshness. */ + refreshable: boolean; +} + +/** + * What the context window meter opens: how full the window is, then the usage of the agent's + * account. The desktop hover card, the native wide tooltip, and the compact sheet all render it. + */ +export function ContextWindowDetails({ + serverId, + agentId, + context, + sessionCost, + showTitle, + refreshable, +}: ContextWindowDetailsProps) { + const { t } = useTranslation(); + return ( + <> + + {showTitle ? {t("contextWindow.title")} : null} + {context ? ( + <> + + {t("contextWindow.used", { percentage: context.percentage })} + + + {t("contextWindow.tokens", { + used: formatTokenCount(context.usedTokens), + max: formatTokenCount(context.maxTokens), + })} + + + ) : ( + {t("contextWindow.noData")} + )} + {sessionCost ? ( + {t("contextWindow.sessionCost", { cost: sessionCost })} + ) : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + gap: theme.spacing[1.5], + minWidth: 200, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: theme.fontSize.base * 1.4, + }, + detail: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, +})); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 6fe837562e..11931bdd41 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,34 +1,34 @@ -import { useCallback, useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import Svg, { Circle } from "react-native-svg"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section"; -import { useProviderUsage } from "@/provider-usage/use-provider-usage"; -import { formatTokenCount } from "./context-window-meter.utils"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { useHostReportsUsage } from "@/usage"; +import type { Theme } from "@/styles/theme"; +import { ContextWindowDetails } from "./context-window-details"; +import { ContextWindowSheet } from "./context-window-sheet"; interface ContextWindowMeterProps { + serverId: string; + agentId: string; maxTokens: number | null; usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - serverId?: string; - /** The Paseo provider key, e.g. "claude", "gemini", "codex" */ - provider?: string | null; - /** Reserve the meter footprint and show a loading ring while usage is pending. */ - pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ glyphSize?: number; } const SVG_SIZE = 14; +const USAGE_POPOVER_WIDTH = 300; const COMPACT_SVG_SIZE = 12; -const COMPACT_CENTER = COMPACT_SVG_SIZE / 2; const COMPACT_RADIUS = 5; const STROKE_WIDTH = 2; const COMPACT_STROKE_WIDTH = 1.75; -const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS; function isValidMaxTokens(value: number): boolean { return Number.isFinite(value) && value > 0; @@ -59,28 +59,22 @@ function formatSessionCost(value: number): string | null { return `$${value.toFixed(2)}`; } -function getMeterColors( - percentage: number, - theme: ReturnType["theme"], -): { progress: string; track: string } { - const track = theme.colors.surface3; +function getProgressColor(percentage: number, theme: Theme): string { if (percentage > 90) { - return { progress: theme.colors.destructive, track }; + return theme.colors.destructive; } if (percentage >= 70) { - return { progress: theme.colors.palette.amber[500], track }; + return theme.colors.palette.amber[500]; } - return { progress: theme.colors.foregroundMuted, track }; + return theme.colors.foregroundMuted; } function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { if (showPercentage) { return { svgSize: COMPACT_SVG_SIZE, - center: COMPACT_CENTER, radius: COMPACT_RADIUS, strokeWidth: COMPACT_STROKE_WIDTH, - circumference: COMPACT_CIRCUMFERENCE, containerStyle: styles.containerWithLabel, }; } @@ -88,155 +82,221 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH; return { svgSize: resolvedSize, - center: resolvedSize / 2, radius: (resolvedSize - resolvedStrokeWidth) / 2, strokeWidth: resolvedStrokeWidth, - circumference: Math.PI * (resolvedSize - resolvedStrokeWidth), containerStyle: styles.container, }; } +// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG. +const ContextWindowRing = withUnistyles(function ContextWindowRing({ + size, + radius, + strokeWidth, + percentage, + trackColor, + progressColor, +}: { + size: number; + radius: number; + strokeWidth: number; + percentage: number | null; + trackColor: string; + progressColor: string; +}) { + const center = size / 2; + const circumference = 2 * Math.PI * radius; + return ( + + + {percentage !== null ? ( + + ) : null} + + ); +}); + export function ContextWindowMeter({ + serverId, + agentId, maxTokens, usedTokens, totalCostUsd, showPercentage = false, - serverId, - provider, - pending = false, glyphSize, }: ContextWindowMeterProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); - const [isTooltipOpen, setIsTooltipOpen] = useState(false); - const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage( - serverId ?? null, - { enabled: isTooltipOpen }, - ); + const { width } = useWindowDimensions(); + // Usage cards need a wider popover; without them it keeps the plain tooltip shape. + const showsUsage = useHostReportsUsage(serverId); + const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24); + // Compact screens open the details in a sheet, which can hold a pressable Refresh. + const isCompact = useIsCompactFormFactor(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const openSheet = useCallback(() => setIsSheetOpen(true), []); + const closeSheet = useCallback(() => setIsSheetOpen(false), []); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; - const handleTooltipOpenChange = useCallback( - (nextOpen: boolean) => { - setIsTooltipOpen(nextOpen); - if (nextOpen) { - void refreshProviderUsage().catch(() => {}); - } - }, - [refreshProviderUsage], - ); - const geometry = getMeterGeometry(showPercentage, glyphSize); - // No usage yet: reserve the footprint with a track-only ring while a session is - // active so the real ring fades in without shifting siblings. Render nothing when - // no usage is expected. - if (percentage === null || maxTokens === null || usedTokens === null) { - if (!pending) { - return null; - } - return ( - - - - - {showPercentage ? : null} - - ); - } - - const clampedPercentage = clampPercentage(percentage); - const roundedPercentage = Math.round(percentage); - const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry; - const dashOffset = circumference - (clampedPercentage / 100) * circumference; - const colors = getMeterColors(clampedPercentage, theme); + const context = useMemo( + () => + percentage !== null && maxTokens !== null && usedTokens !== null + ? { percentage: Math.round(percentage), maxTokens, usedTokens } + : null, + [percentage, maxTokens, usedTokens], + ); + const meterColors = useCallback( + (theme: Theme) => ({ + progressColor: getProgressColor(percentage ?? 0, theme), + trackColor: theme.colors.surface3, + }), + [percentage], + ); const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; + const containerStyle = geometry.containerStyle; + const ring = ( + + ); + const percentageLabel = + showPercentage && context ? ( + {`${context.percentage}%`} + ) : null; + const accessibilityLabel = context + ? t("contextWindow.accessibility", { percentage: context.percentage }) + : t("contextWindow.accessibilityNoData"); - return ( - - + if (isCompact) { + return ( + <> - - - - - {showPercentage ? ( - {`${roundedPercentage}%`} - ) : null} + {ring} + {percentageLabel} - - - - {t("contextWindow.title")} - - {t("contextWindow.used", { percentage: roundedPercentage })} - - - {t("contextWindow.tokens", { - used: formatTokenCount(usedTokens), - max: formatTokenCount(maxTokens), - })} - - {formattedSessionCost ? ( - - {t("contextWindow.sessionCost", { cost: formattedSessionCost })} - - ) : null} - + + + + + ); + } + + const popoverStyle = showsUsage + ? [styles.usagePopover, { width: popoverWidth }] + : styles.plainPopover; + + // Native wide screens have no hover, so the details open in a tooltip on press. The tooltip + // takes no presses, so its usage cards have no Refresh. + if (isNative) { + return ( + + + + {ring} + {percentageLabel} + + + + + + + ); + } + + return ( + + + + {ring} + {percentageLabel} - - + + + + + ); } @@ -256,36 +316,12 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], borderRadius: theme.borderRadius.full, }, - svg: { - transform: [{ rotate: "-90deg" }], - }, percentageLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - skeletonLabel: { - width: 22, - height: theme.fontSize.base, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface3, - }, - tooltipContent: { - gap: theme.spacing[1.5], - minWidth: 200, - }, - tooltipTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - tooltipText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - lineHeight: theme.fontSize.base * 1.4, - }, - tooltipDetail: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, + // Plain details use a small inset; account usage cards have their own content density. + plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] }, + usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] }, })); diff --git a/packages/app/src/components/context-window-sheet.tsx b/packages/app/src/components/context-window-sheet.tsx new file mode 100644 index 0000000000..5dd454e048 --- /dev/null +++ b/packages/app/src/components/context-window-sheet.tsx @@ -0,0 +1,80 @@ +import { useMemo, type ReactNode } from "react"; +import { View, useWindowDimensions } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SheetHeaderView, SHEET_HORIZONTAL_PADDING_SCALE } from "@/components/adaptive-modal-sheet"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; + +// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not +// track, so the sheet is wrapped rather than reading the theme through a hook. +// See docs/unistyles.md. +const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ + backgroundStyle: { + backgroundColor: theme.colors.surface0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + }, + handleIndicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +/** + * The context window meter's details on compact screens, in a sheet as tall as its content. + * Gorhom mounts the content only while the sheet is presented, so whatever it fetches stops once + * the sheet closes. + */ +export function ContextWindowSheet({ + open, + onClose, + children, +}: { + open: boolean; + onClose: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const header = useMemo(() => ({ title: t("contextWindow.title") }), [t]); + const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ + visible: open, + onClose, + }); + + return ( + + + + + {/* The inset sits on a View: a themed contentContainerStyle resolves to nothing on web. */} + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], + gap: theme.spacing[4], + }, +})); diff --git a/packages/app/src/components/desktop/titlebar-drag-region.tsx b/packages/app/src/components/desktop/titlebar-drag-region.tsx index 3855489d50..da115c7349 100644 --- a/packages/app/src/components/desktop/titlebar-drag-region.tsx +++ b/packages/app/src/components/desktop/titlebar-drag-region.tsx @@ -17,15 +17,20 @@ import { isNative } from "@/constants/platform"; * On macOS, Electron handles edge resize natively. */ +export const titlebarDragSurfaceStyle: React.CSSProperties = { + cursor: "default", + // @ts-expect-error — WebkitAppRegion is not in CSSProperties + WebkitAppRegion: "drag", +}; + const DRAG_OVERLAY_STYLE: React.CSSProperties = { + ...titlebarDragSurfaceStyle, top: 0, left: 0, display: "block", position: "absolute", width: "100%", height: "100%", - // @ts-expect-error — WebkitAppRegion is not in CSSProperties - WebkitAppRegion: "drag", }; const TOP_RESIZER_STYLE: React.CSSProperties = { diff --git a/packages/app/src/components/desktop/window-controls.tsx b/packages/app/src/components/desktop/window-controls.tsx new file mode 100644 index 0000000000..615214e95e --- /dev/null +++ b/packages/app/src/components/desktop/window-controls.tsx @@ -0,0 +1,208 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, View } from "react-native"; +import Svg, { Line, Rect } from "react-native-svg"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + closeDesktopWindow, + minimizeDesktopWindow, + toggleDesktopMaximize, +} from "@/desktop/electron/window"; +import { + getDesktopWindowControlsPresentation, + type DesktopWindowControlsPresentation, +} from "@/desktop/window-chrome-presentation"; +import { useCustomDesktopWindowControls } from "@/utils/desktop-window"; +import type { Theme } from "@/styles/theme"; + +type WindowControlKind = "minimize" | "maximize" | "restore" | "close"; + +function WindowControlGlyph({ + kind, + color, + size, + strokeWidth, +}: { + kind: WindowControlKind; + color: string; + size: number; + strokeWidth: number; +}) { + if (kind === "minimize") { + return ( + + + + ); + } + if (kind === "restore") { + return ( + + + + + + ); + } + if (kind === "close") { + return ( + + + + + ); + } + return ( + + + + ); +} + +const ThemedWindowControlGlyph = withUnistyles(WindowControlGlyph); +const mutedGlyphMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const whiteGlyphMapping = () => ({ color: "#ffffff" }); + +function WindowControl({ + kind, + label, + onPress, + presentation, +}: { + kind: WindowControlKind; + label: string; + onPress: () => void; + presentation: DesktopWindowControlsPresentation; +}) { + const glyph = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => { + const highlighted = hovered || pressed; + const closeHighlighted = kind === "close" && highlighted; + return ( + + + + ); + }, + [kind, presentation], + ); + const controlStyle = useMemo( + () => [styles.control, { width: presentation.controlWidth }], + [presentation.controlWidth], + ); + return ( + + {glyph} + + ); +} + +export function DesktopWindowControls() { + const { t } = useTranslation(); + const { visible, mode, isMaximized } = useCustomDesktopWindowControls(); + const minimize = useCallback(() => void minimizeDesktopWindow(), []); + const toggleMaximize = useCallback(() => void toggleDesktopMaximize(), []); + const close = useCallback(() => void closeDesktopWindow(), []); + if (!visible || !mode) return null; + const presentation = getDesktopWindowControlsPresentation(mode); + + return ( + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + controls: { + position: "absolute", + top: 0, + right: 0, + zIndex: 3000, + flexDirection: "row", + }, + control: { + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + controlSurface: { + alignItems: "center", + justifyContent: "center", + }, + fullControlSurface: { + width: "100%", + height: "100%", + }, + circularControlSurface: { + width: 24, + height: 24, + borderRadius: 12, + }, + glyph: { + flexShrink: 0, + }, + controlHovered: { + backgroundColor: theme.colors.interactionHighlight, + }, + closeHovered: { + backgroundColor: "rgba(232, 17, 35, 0.9)", + }, +})); diff --git a/packages/app/src/components/diff-stat.tsx b/packages/app/src/components/diff-stat.tsx index c8c9bf9479..053535aa06 100644 --- a/packages/app/src/components/diff-stat.tsx +++ b/packages/app/src/components/diff-stat.tsx @@ -1,5 +1,6 @@ import { View, Text } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { formatDiffCount } from "@/git/file-header-presentation"; interface DiffStatProps { additions: number; @@ -7,15 +8,6 @@ interface DiffStatProps { testID?: string; } -const compactFormatter = new Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 1, -}); - -export function formatDiffCount(value: number): string { - return compactFormatter.format(value).toLowerCase(); -} - export function DiffStat({ additions, deletions, testID }: DiffStatProps) { return ( diff --git a/packages/app/src/components/explorer-sidebar-layout.test.ts b/packages/app/src/components/explorer-sidebar-layout.test.ts new file mode 100644 index 0000000000..0570144000 --- /dev/null +++ b/packages/app/src/components/explorer-sidebar-layout.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + explorerSidebarCloseButtonLayout, + resolveExplorerSidebarDockSizes, + resolveExplorerSidebarWidth, +} from "@/components/explorer-sidebar-layout"; + +describe("Explorer sidebar layout", () => { + it("keeps the sidebar width fixed when the workspace body changes size", () => { + const narrow = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1200 }); + const wide = resolveExplorerSidebarDockSizes({ requestedWidth: 320, containerWidth: 1520 }); + + expect(narrow[1] * 1200).toBeCloseTo(320); + expect(wide[1] * 1520).toBeCloseTo(320); + }); + + it("has no fixed maximum while preserving room for the workspace body", () => { + expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240); + expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900); + expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800); + expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350); + }); +}); + +describe("Explorer close action", () => { + it("preserves the wide-native touch target and glyph rail", () => { + const layout = explorerSidebarCloseButtonLayout(false); + expect(layout.size).toBe(34); + expect(layout.iconSize).toBe(18); + expect(layout.size + layout.hitSlop * 2).toBe(50); + expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16); + }); + + it("keeps the compact close glyph on the pane rail", () => { + const layout = explorerSidebarCloseButtonLayout(true); + expect(layout.size).toBe(32); + expect(layout.iconSize).toBe(18); + expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8); + }); +}); diff --git a/packages/app/src/components/explorer-sidebar-layout.ts b/packages/app/src/components/explorer-sidebar-layout.ts new file mode 100644 index 0000000000..5cd5352556 --- /dev/null +++ b/packages/app/src/components/explorer-sidebar-layout.ts @@ -0,0 +1,52 @@ +import { + paneContentToolbarIconSize, + paneContentToolbarTrailingPadding, +} from "@/components/ui/pane-content-toolbar"; +import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome"; + +export function explorerSidebarCloseButtonLayout(compact: boolean) { + // The inline native dock retains its padded close action and touch slop. + if (!compact) { + return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 }; + } + return { + size: smallIconButtonChromeFrameSize(compact), + iconSize: paneContentToolbarIconSize(compact), + hitSlop: 0, + trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"), + }; +} + +/** + * Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts + * the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`). + */ +export const EXPLORER_TAB_RAIL_INSET = 4; + +const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320; +const MIN_EXPLORER_SIDEBAR_WIDTH = 240; +const MIN_WORKSPACE_BODY_WIDTH = 400; + +export function resolveExplorerSidebarWidth(input: { + requestedWidth?: number; + containerWidth: number; +}): number { + const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH; + const maximumVisibleWidth = + input.containerWidth > 0 + ? Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, input.containerWidth - MIN_WORKSPACE_BODY_WIDTH) + : requestedWidth; + return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth)); +} + +export function resolveExplorerSidebarDockSizes(input: { + requestedWidth?: number; + containerWidth: number; +}): number[] { + if (input.containerWidth <= 0) { + return [1, 0]; + } + const width = resolveExplorerSidebarWidth(input); + const ratio = width / input.containerWidth; + return [1 - ratio, ratio]; +} diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx index 63de27475e..84a140021c 100644 --- a/packages/app/src/components/file-actions-menu.tsx +++ b/packages/app/src/components/file-actions-menu.tsx @@ -1,9 +1,11 @@ -import { Fragment, useMemo, type ReactElement, type ReactNode } from "react"; +import { Fragment, useMemo, type ReactElement } from "react"; import { withUnistyles } from "react-native-unistyles"; import { + ArrowRightToLine, Copy, CopyPlus, Download, + ExternalLink, FilePlus, FileText, FolderMinus, @@ -25,8 +27,11 @@ import { const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); +type FileActionGroup = "create" | "open" | "reference" | "manage" | "destructive"; + interface FileAction { key: string; + group: FileActionGroup; label: string; icon: LucideIcon; onSelect: () => void; @@ -35,10 +40,21 @@ interface FileAction { testID?: string; } +function optionalFileAction( + available: boolean, + onSelect: (() => void) | undefined, + action: Omit, +): FileAction | null { + return available && onSelect ? { ...action, onSelect } : null; +} + interface FileActionsContextMenuContentProps { fileKind: "file" | "directory"; fileExists?: boolean; onOpenFile?: () => void; + onOpenInEditor?: () => void; + editorTargetName?: string; + onOpenToSide?: () => void; onCopyPath?: () => void; onCopyRelativePath?: () => void; onReveal?: () => void; @@ -52,8 +68,6 @@ interface FileActionsContextMenuContentProps { onDuplicate?: () => void; onRevert?: () => void; onDelete?: () => void; - /** Optional metadata block rendered above the actions (e.g. size/modified). */ - header?: ReactNode; testIDPrefix?: string; } @@ -65,6 +79,9 @@ export function FileActionsContextMenuContent({ fileKind, fileExists = true, onOpenFile, + onOpenInEditor, + editorTargetName, + onOpenToSide, onCopyPath, onCopyRelativePath, onReveal, @@ -78,16 +95,29 @@ export function FileActionsContextMenuContent({ onDuplicate, onRevert, onDelete, - header, testIDPrefix, }: FileActionsContextMenuContentProps): ReactElement | null { const { t } = useTranslation(); + const openInEditorAction = useMemo( + () => + fileKind === "directory" && onOpenInEditor && editorTargetName + ? { + key: "open-in-editor", + group: "open", + label: t("workspace.fileActions.openIn", { target: editorTargetName }), + icon: ExternalLink, + onSelect: onOpenInEditor, + } + : null, + [editorTargetName, fileKind, onOpenInEditor, t], + ); const actions = useMemo(() => { const availableFile = fileKind === "file" && fileExists; const specs: Array = [ onNewFile ? { key: "new-file", + group: "create", label: t("workspace.fileActions.newFile"), icon: FilePlus, onSelect: onNewFile, @@ -96,6 +126,7 @@ export function FileActionsContextMenuContent({ onNewFolder ? { key: "new-folder", + group: "create", label: t("workspace.fileActions.newFolder"), icon: FolderPlus, onSelect: onNewFolder, @@ -104,6 +135,7 @@ export function FileActionsContextMenuContent({ onCollapseFolder ? { key: "collapse-folder", + group: "open", label: t("workspace.fileActions.collapseFolder"), icon: FolderMinus, onSelect: onCollapseFolder, @@ -112,30 +144,23 @@ export function FileActionsContextMenuContent({ availableFile && onOpenFile ? { key: "open-file", + group: "open", label: t("workspace.fileActions.openFile"), icon: FileText, onSelect: onOpenFile, } : null, - onRename - ? { - key: "rename", - label: t("workspace.fileActions.rename"), - icon: Pencil, - onSelect: onRename, - } - : null, - onDuplicate - ? { - key: "duplicate", - label: t("workspace.fileActions.duplicate"), - icon: CopyPlus, - onSelect: onDuplicate, - } - : null, + openInEditorAction, + optionalFileAction(availableFile, onOpenToSide, { + key: "open-to-side", + group: "open", + label: t("workspace.fileActions.openToSide"), + icon: ArrowRightToLine, + }), onCopyPath ? { key: "copy-path", + group: "reference", label: t("workspace.fileActions.copyPath"), icon: Copy, onSelect: onCopyPath, @@ -144,6 +169,7 @@ export function FileActionsContextMenuContent({ onCopyRelativePath ? { key: "copy-relative-path", + group: "reference", label: t("workspace.fileActions.copyRelativePath"), icon: Copy, onSelect: onCopyRelativePath, @@ -152,6 +178,7 @@ export function FileActionsContextMenuContent({ onReveal && revealTargetName ? { key: "reveal", + group: "reference", label: t("workspace.fileActions.revealIn", { target: revealTargetName }), icon: FolderOpen, onSelect: onReveal, @@ -160,6 +187,7 @@ export function FileActionsContextMenuContent({ availableFile && onDownload ? { key: "download", + group: "reference", label: t("workspace.fileActions.download"), icon: Download, onSelect: onDownload, @@ -168,14 +196,34 @@ export function FileActionsContextMenuContent({ availableFile && onAddToChat ? { key: "add-to-chat", + group: "reference", label: t("workspace.fileActions.addToChat"), icon: MessageSquarePlus, onSelect: onAddToChat, } : null, + onRename + ? { + key: "rename", + group: "manage", + label: t("workspace.fileActions.rename"), + icon: Pencil, + onSelect: onRename, + } + : null, + onDuplicate + ? { + key: "duplicate", + group: "manage", + label: t("workspace.fileActions.duplicate"), + icon: CopyPlus, + onSelect: onDuplicate, + } + : null, onRevert ? { key: "revert", + group: "destructive", label: t("workspace.fileActions.revert"), icon: Undo2, onSelect: onRevert, @@ -185,6 +233,7 @@ export function FileActionsContextMenuContent({ onDelete ? { key: "delete", + group: "destructive", label: t("workspace.fileActions.delete"), icon: Trash2, onSelect: onDelete, @@ -195,9 +244,7 @@ export function FileActionsContextMenuContent({ const availableActions = specs.filter((action): action is FileAction => action !== null); return availableActions.map((action, index) => Object.assign(action, { - separatorBefore: Boolean( - action.destructive && index > 0 && !availableActions[index - 1].destructive, - ), + separatorBefore: index > 0 && action.group !== availableActions[index - 1]?.group, testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined, }), ); @@ -214,6 +261,8 @@ export function FileActionsContextMenuContent({ onNewFile, onNewFolder, onOpenFile, + openInEditorAction, + onOpenToSide, onRename, onReveal, onRevert, @@ -231,12 +280,6 @@ export function FileActionsContextMenuContent({ width={220} testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined} > - {header ? ( - <> - {header} - - - ) : null} {actions.map((action) => ( {action.separatorBefore ? : null} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index e2c3d9d5eb..209ec74f28 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -41,7 +41,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { PaneContentToolbar, paneContentToolbarIconSize, - paneContentToolbarIconButtonStyle, + paneContentToolbarTrailingPadding, + ToolbarButton, + ToolbarControls, } from "@/components/ui/pane-content-toolbar"; import { useOverlayFlatListScrollbar, @@ -57,11 +59,10 @@ import { useSessionStore } from "@/stores/session-store"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; -import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; -import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { isHiddenExplorerPath } from "@/file-explorer/visibility"; import { @@ -76,6 +77,7 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra import { confirmDialog } from "@/utils/confirm-dialog"; import { useToast } from "@/contexts/toast-context"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -100,16 +102,6 @@ function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expande return ; } -function formatFileSize({ size }: { size: number }): string { - if (size < 1024) { - return `${size} B`; - } - if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(1)} KB`; - } - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - interface TreeRowItemProps { serverId: string; workspaceId?: string | null; @@ -122,10 +114,13 @@ interface TreeRowItemProps { onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void; onCopyRelativePath: (path: string) => void; + onOpenInEditor?: (entry: ExplorerEntry) => void; + editorTargetName?: string; onRevealEntry?: (entry: ExplorerEntry) => void; revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; onAddToChat?: (path: string) => void; + onOpenFileToSide?: (path: string) => void; onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; onCollapseDirectory?: (path: string) => void; onRenameEntry?: (entry: ExplorerEntry) => void; @@ -141,13 +136,6 @@ function sortTriggerStyle({ return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered]; } -function iconButtonStyle( - isCompact: boolean, - { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, -) { - return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact); -} - type ExplorerListRow = | { type: "entry"; row: ExplorerTreeRow } | { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number } @@ -252,10 +240,13 @@ function TreeRowItem({ onSelectEntry, onCopyPath, onCopyRelativePath, + onOpenInEditor, + editorTargetName, onRevealEntry, revealTargetName, onDownloadEntry, onAddToChat, + onOpenFileToSide, onNewEntry, onCollapseDirectory, onRenameEntry, @@ -263,7 +254,6 @@ function TreeRowItem({ onDeleteEntry, testID, }: TreeRowItemProps) { - const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const showNameHover = useCallback(() => setIsHovered(true), []); const hideNameHover = useCallback(() => setIsHovered(false), []); @@ -305,6 +295,10 @@ function TreeRowItem({ onCopyRelativePath(entry.path); }, [onCopyRelativePath, entry.path]); + const handleOpenInEditor = useCallback(() => { + onOpenInEditor?.(entry); + }, [entry, onOpenInEditor]); + const handleReveal = useCallback(() => { onRevealEntry?.(entry); }, [onRevealEntry, entry]); @@ -317,6 +311,10 @@ function TreeRowItem({ onAddToChat?.(entry.path); }, [onAddToChat, entry.path]); + const handleOpenToSide = useCallback(() => { + onOpenFileToSide?.(entry.path); + }, [entry.path, onOpenFileToSide]); + const handleNewFile = useCallback(() => { onNewEntry?.(entry.path, "file"); }, [onNewEntry, entry.path]); @@ -341,30 +339,6 @@ function TreeRowItem({ onDeleteEntry?.(entry); }, [onDeleteEntry, entry]); - const metaHeader = useMemo( - () => ( - - - - {t("workspace.fileExplorer.context.size")} - - - {formatFileSize({ size: entry.size })} - - - - - {t("workspace.fileExplorer.context.modified")} - - - {formatTimeAgo(new Date(entry.modifiedAt))} - - - - ), - [entry.modifiedAt, entry.size, t], - ); - return ( @@ -425,6 +401,7 @@ interface FileExplorerPaneProps { workspaceId?: string | null; workspaceRoot: string; onOpenFile?: (filePath: string) => void; + onOpenFileToSide?: (filePath: string) => void; onAddToChat?: (path: string) => void; } @@ -433,6 +410,7 @@ export function FileExplorerPane({ workspaceId, workspaceRoot, onOpenFile, + onOpenFileToSide, onAddToChat, }: FileExplorerPaneProps) { const { t } = useTranslation(); @@ -472,6 +450,10 @@ export function FileExplorerPane({ isLocalExecution: isLocalDaemon, }); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); + const openDirectoryInEditor = useOpenDirectoryInEditor({ + serverId, + workspaceDirectory: normalizedWorkspaceRoot, + }); // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. const fsEntryOpsEnabled = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true, @@ -641,6 +623,11 @@ export function FileExplorerPane({ [fileManagerTarget, normalizedWorkspaceRoot, t, toast], ); + const handleOpenDirectoryInEditor = useCallback( + (entry: ExplorerEntry) => openDirectoryInEditor?.open(entry.path), + [openDirectoryInEditor], + ); + const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (entry.kind !== "file") { @@ -992,10 +979,13 @@ export function FileExplorerPane({ onSelectEntry={handleSelectEntry} onCopyPath={handleCopyPath} onCopyRelativePath={handleCopyRelativePath} + onOpenInEditor={openDirectoryInEditor ? handleOpenDirectoryInEditor : undefined} + editorTargetName={openDirectoryInEditor?.targetName} onRevealEntry={fileManagerTarget ? handleRevealEntry : undefined} revealTargetName={fileManagerTarget?.label} onDownloadEntry={handleDownloadEntry} onAddToChat={onAddToChat} + onOpenFileToSide={onOpenFileToSide} onNewEntry={fsEntryOpsEnabled ? handleNewEntry : undefined} onCollapseDirectory={handleCollapseDirectory} onRenameEntry={fsEntryOpsEnabled ? handleRenameEntry : undefined} @@ -1011,6 +1001,7 @@ export function FileExplorerPane({ handleCollapseDirectory, handleCopyPath, handleCopyRelativePath, + handleOpenDirectoryInEditor, handleDeleteEntry, handleDownloadEntry, handleDraftCommit, @@ -1024,8 +1015,10 @@ export function FileExplorerPane({ handleSelectEntry, isDirectoryLoading, fileManagerTarget, + openDirectoryInEditor, selectedEntryPath, onAddToChat, + onOpenFileToSide, serverId, workspaceId, ], @@ -1049,11 +1042,6 @@ export function FileExplorerPane({ }); }, [requestDirectoryListing]); - const toolbarIconButtonStyle = useCallback( - (state: PressableStateCallbackType) => iconButtonStyle(isCompact, state), - [isCompact], - ); - if (!hasWorkspaceScope) { return ( @@ -1087,7 +1075,6 @@ export function FileExplorerPane({ handleBackFromError={handleBackFromError} handleRetry={handleRetry} sortTriggerStyle={sortTriggerStyle} - iconButtonStyle={toolbarIconButtonStyle} /> ); @@ -1162,7 +1149,6 @@ interface FileExplorerPaneContentProps { handleBackFromError: () => void; handleRetry: () => void; sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp; - iconButtonStyle: (state: PressableStateCallbackType) => StyleProp; } function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { @@ -1186,7 +1172,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { handleBackFromError, handleRetry, sortTriggerStyle: sortTriggerStyleProp, - iconButtonStyle: iconButtonStyleProp, } = props; const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); @@ -1204,17 +1189,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { const emptyLabel = showHiddenFiles ? t("workspace.fileExplorer.empty.noFiles") : t("workspace.fileExplorer.empty.noVisibleFiles"); - const hiddenFilesToggleStyle = useCallback( - (state: PressableStateCallbackType) => [ - iconButtonStyleProp(state), - !showHiddenFiles && styles.iconButtonActive, - ], - [showHiddenFiles, iconButtonStyleProp], - ); - const hiddenFilesToggleAccessibilityState = useMemo( - () => ({ selected: !showHiddenFiles }), - [showHiddenFiles], - ); if (error) { return ( @@ -1245,7 +1219,13 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { return ( - + - + {onNewEntryAtRoot ? ( <> - - - + - + ) : null} - {showHiddenFiles ? ( ) : ( )} - - + {isRefreshFetching ? ( ) : ( )} - - + + @@ -1475,10 +1451,13 @@ function TreeRowDispatcher({ onSelectEntry, onCopyPath, onCopyRelativePath, + onOpenInEditor, + editorTargetName, onRevealEntry, revealTargetName, onDownloadEntry, onAddToChat, + onOpenFileToSide, onNewEntry, onCollapseDirectory, onRenameEntry, @@ -1496,10 +1475,13 @@ function TreeRowDispatcher({ onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void | Promise; onCopyRelativePath: (path: string) => void | Promise; + onOpenInEditor?: (entry: ExplorerEntry) => void; + editorTargetName?: string; onRevealEntry?: (entry: ExplorerEntry) => void; revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; onAddToChat?: (path: string) => void; + onOpenFileToSide?: (path: string) => void; onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; onCollapseDirectory?: (path: string) => void; onRenameEntry?: (entry: ExplorerEntry) => void; @@ -1526,10 +1508,13 @@ function TreeRowDispatcher({ onSelectEntry={onSelectEntry} onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} + onOpenInEditor={onOpenInEditor} + editorTargetName={editorTargetName} onRevealEntry={onRevealEntry} revealTargetName={revealTargetName} onDownloadEntry={onDownloadEntry} onAddToChat={onAddToChat} + onOpenFileToSide={onOpenFileToSide} onNewEntry={onNewEntry} onCollapseDirectory={onCollapseDirectory} onRenameEntry={onRenameEntry} @@ -1698,7 +1683,7 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingRight: theme.spacing[3], + backgroundColor: theme.colors.surfaceSidebar, }, sortTrigger: { flexDirection: "row", @@ -1711,7 +1696,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.base, }, sortTriggerHovered: { - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surfaceSidebarHover, }, sortTriggerText: { fontSize: theme.fontSize.sm, @@ -1720,7 +1705,6 @@ const styles = StyleSheet.create((theme) => ({ headerActions: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], }, treeList: { flex: 1, @@ -1807,38 +1791,12 @@ const styles = StyleSheet.create((theme) => ({ draftPlaceholder: { color: theme.colors.foregroundExtraMuted, }, - contextMetaBlock: { - paddingVertical: theme.spacing[1], - }, - contextMetaRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - minHeight: 32, - paddingHorizontal: theme.spacing[3], - }, - contextMetaLabel: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - flexShrink: 0, - }, - contextMetaValue: { - fontSize: theme.fontSize.base, - color: theme.colors.foreground, - fontWeight: theme.fontWeight.medium, - flex: 1, - minWidth: 0, - textAlign: "right", - }, previewHeaderText: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - iconButtonActive: { - backgroundColor: theme.colors.surface2, - }, refreshIcon: { width: 16, height: 16, diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx new file mode 100644 index 0000000000..b18a8731b0 --- /dev/null +++ b/packages/app/src/components/floating-header/index.tsx @@ -0,0 +1,99 @@ +import { useCallback, useId, useState, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; + +/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ +const FADE_OVERHANG = 24; + +interface FloatingHeaderLayoutProps { + header: ReactNode; + /** Float only when the content supports visual-top clearance. */ + floating: boolean; + children: (topOverlayInset: number) => ReactNode; +} + +/** + * Stacks a header above content, or floats it over the content behind a fade. Children keep the + * same tree position in both modes, so toggling `floating` never remounts them. + */ +export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) { + const [headerHeight, setHeaderHeight] = useState(0); + const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { + setHeaderHeight(Math.round(event.nativeEvent.layout.height)); + }, []); + + return ( + + {floating ? null : header} + {children(floating ? headerHeight : 0)} + {floating ? ( + + + {header} + + ) : null} + + ); +} + +function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) { + return ( + + + + {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in + some engines and leaves a grey fringe. */} + + + + + + + + + ); +} + +const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg); +const workspaceSurfaceColorMapping = (theme: Theme) => ({ + color: theme.colors.surfaceWorkspace, +}); + +function FloatingHeaderFade() { + // React-generated ids contain characters that are invalid inside SVG fragment references. + const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + }, + content: { + flex: 1, + minHeight: 0, + }, + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + }, + fade: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: -FADE_OVERHANG, + }, +}); diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index 6532ee561a..a9398da0cf 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -6,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { ScreenTitle } from "./screen-title"; +import { CompactHeader } from "./compact-header"; +import { useIsCompactFormFactor } from "@/constants/layout"; interface BackHeaderProps { title?: string; - titleAccessory?: ReactNode; rightContent?: ReactNode; onBack?: () => void; } @@ -18,7 +19,15 @@ function goBack(): void { router.back(); } -export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { +export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { + const isCompact = useIsCompactFormFactor(); + if (isCompact) { + return ; + } + return ; +} + +function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const handleBack = useCallback(() => { @@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back {title && {title}} - {titleAccessory} } right={rightContent} @@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, backButton: { - padding: { - xs: theme.spacing[3], - md: theme.spacing[2], - }, + padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, })); diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx new file mode 100644 index 0000000000..aa66580fce --- /dev/null +++ b/packages/app/src/components/headers/compact-header.tsx @@ -0,0 +1,168 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { router } from "expo-router"; +import { ArrowLeft } from "lucide-react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; +import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +const ThemedArrowLeft = withUnistyles(ArrowLeft); + +const ISLAND_PADDING = 7; +const NO_SHORTCUT: ShortcutKey[] = []; + +interface CompactHeaderProps { + /** The leading island: the sidebar menu, or a back button. */ + navigation: "menu" | "back"; + /** Back button action. Defaults to navigating back. */ + onBack?: () => void; + title?: string; + titleTestID?: string; + /** A line under the title. */ + subtitle?: ReactNode; + /** Show a placeholder instead of the title while it loads. */ + loading?: boolean; + /** Icon actions inherit the island chrome through the shared button primitives. */ + actions?: ReactNode; +} + +/** + * The phone header: the navigation button and the screen's actions sit in islands, with the title + * left-aligned between them. It has no background of its own, so it reads the same in flow and floating + * over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything + * offset by the header height still lines up. + */ +export function CompactHeader({ + navigation, + onBack, + title, + titleTestID, + subtitle, + loading = false, + actions, +}: CompactHeaderProps) { + const insets = useSafeAreaInsets(); + const rootStyle = useMemo( + () => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }], + [insets.top], + ); + + return ( + + + + + {navigation === "menu" ? : } + + + + + {actions ? {actions} : null} + + + + ); +} + +function CompactHeaderTitle({ + loading, + title, + titleTestID, + subtitle, +}: Pick & { loading: boolean }) { + if (loading) { + return ; + } + return ( + <> + {title ? ( + + {title} + + ) : null} + {subtitle} + + ); +} + +function BackButton({ onBack }: { onBack?: () => void }) { + const { t } = useTranslation(); + const handlePress = useCallback(() => { + if (onBack) { + onBack(); + return; + } + router.back(); + }, [onBack]); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + root: { + paddingHorizontal: theme.spacing[3], + }, + row: { + height: HEADER_INNER_HEIGHT_MOBILE, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + titleGroup: { + flex: 1, + minWidth: 0, + // Both title lines receive the remaining width; text alignment does not size the slot. + alignItems: "stretch", + }, + island: { + flexDirection: "row", + alignItems: "center", + padding: ISLAND_PADDING, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + ...theme.shadow.sm, + }, + title: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + textAlign: "left", + }, + titleSkeleton: { + width: 140, + maxWidth: "100%", + height: 22, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + opacity: 0.25, + }, +})); diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index d6ac94d354..137d5d483d 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -1,10 +1,14 @@ -import { useMemo, type ReactElement, type ReactNode } from "react"; +import { type ReactElement, type ReactNode } from "react"; import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { isWeb } from "@/constants/platform"; +import { + iconButtonChromeFrameStyle, + useIconButtonChromeStyle, +} from "@/components/ui/icon-button-chrome"; interface HeaderToggleButtonState { hovered: boolean; @@ -42,7 +46,11 @@ export function HeaderToggleButton({ ? ({ "aria-expanded": expandedState } as Record) : null; - const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]); + const combinedStyle = useIconButtonChromeStyle({ + size: "large", + disabled: Boolean(disabled), + style, + }); return ( @@ -68,16 +76,6 @@ export function HeaderToggleButton({ ); } -export const headerIconSlotStyle = StyleSheet.create((theme) => ({ - slot: { - padding: { - xs: theme.spacing[3], - md: theme.spacing[2], - }, - borderRadius: theme.borderRadius.lg, - }, -})); - const styles = StyleSheet.create((theme) => ({ tooltipRow: { flexDirection: "row", @@ -90,3 +88,7 @@ const styles = StyleSheet.create((theme) => ({ }, shortcut: {}, })); + +export const headerIconSlotStyle = { + slot: iconButtonChromeFrameStyle("large"), +}; diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 1d251440af..fc4f2442ff 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,15 +1,10 @@ -import { useCallback, useMemo, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { View, type StyleProp, type ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { PanelLeft } from "lucide-react-native"; +import type { ReactNode } from "react"; +import { StyleSheet } from "react-native-unistyles"; import { ScreenHeader } from "./screen-header"; import { ScreenTitle } from "./screen-title"; -import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button"; -import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { CompactHeader } from "./compact-header"; +import { SidebarMenuToggle } from "./sidebar-menu-toggle"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { getShortcutOs } from "@/utils/shortcut-platform"; -import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window"; interface MenuHeaderProps { title?: string; @@ -17,128 +12,11 @@ interface MenuHeaderProps { borderless?: boolean; } -interface SidebarMenuToggleProps { - style?: StyleProp; - tooltipSide?: "left" | "right" | "top" | "bottom"; - testID?: string; - nativeID?: string; -} - -const MOBILE_MENU_LINE_WIDTH = 16; -const MOBILE_MENU_LINE_SHORT_WIDTH = 8; -const MOBILE_MENU_LINE_HEIGHT = 2; - -function MobileMenuIcon({ color }: { color: string }) { - const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]); - const shortLineStyle = useMemo( - () => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }], - [color], - ); - return ( - - - - - - ); -} - -function SidebarMenuToggleButton({ - isMobile, - extraMutedIdleIcon = false, - resolvedStyle, - tooltipSide = "right", - testID = "menu-button", - nativeID = "menu-button", -}: Omit & { - isMobile: boolean; - extraMutedIdleIcon?: boolean; - resolvedStyle: StyleProp; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); - const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); - const toggleShortcutKeys = useMemo( - () => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]), - [], - ); - - const handlePress = useCallback(() => { - toggleAgentListForLayout({ isCompact: isMobile }); - }, [toggleAgentListForLayout, isMobile]); - - const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]); - - return ( - - {({ hovered, pressed }) => { - let color = extraMutedIdleIcon - ? theme.colors.foregroundExtraMuted - : theme.colors.foregroundMuted; - if (hovered || pressed) { - color = theme.colors.foreground; - } - return isMobile ? ( - - ) : ( - - ); - }} - - ); -} - -export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { - const isMobile = useIsCompactFormFactor(); - const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); - const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); - const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); - const placeholderStyle = useMemo( - () => [headerIconSlotStyle.slot, resolvedStyle], - [resolvedStyle], - ); - - if (!isMobile && !ownsTopLeft) { - return null; - } - - if (!isMobile && hasTopLeftWindowControls) { - return ( - - - - ); - } - - return ; -} - -export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { - const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); - return ( - - ); -} - export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) { + const isCompact = useIsCompactFormFactor(); + if (isCompact) { + return ; + } return ( ({ - leadingToggle: { - marginLeft: { - xs: 0, - md: -theme.spacing[2], - }, - }, left: { gap: theme.spacing[2], }, - mobileMenuIcon: { - width: MOBILE_MENU_LINE_WIDTH, - height: 12, - justifyContent: "space-between", - alignItems: "flex-start", - }, - desktopMenuIconSpace: { - width: theme.iconSize.md, - height: theme.iconSize.md, - }, - mobileMenuLine: { - width: MOBILE_MENU_LINE_WIDTH, - height: MOBILE_MENU_LINE_HEIGHT, - borderRadius: theme.borderRadius.full, - }, - mobileMenuLineShort: { - width: MOBILE_MENU_LINE_SHORT_WIDTH, - }, })); diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index c2bd2f5d3a..6492658389 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -1,3 +1,4 @@ +import { ComposerDockBackground } from "@/composer/dock"; import { useMemo, type ReactNode } from "react"; import type { LayoutChangeEvent } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native"; @@ -49,7 +50,7 @@ export function ScreenHeader({ const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]); return ( - + {right} - + ); } diff --git a/packages/app/src/components/headers/sidebar-menu-toggle.tsx b/packages/app/src/components/headers/sidebar-menu-toggle.tsx new file mode 100644 index 0000000000..445b7baa96 --- /dev/null +++ b/packages/app/src/components/headers/sidebar-menu-toggle.tsx @@ -0,0 +1,167 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { PanelLeft } from "lucide-react-native"; +import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button"; +import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window"; +import { + extraMutedIconColorMapping, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; + +const ThemedPanelLeft = withUnistyles(PanelLeft); + +interface SidebarMenuToggleProps { + style?: StyleProp; + tooltipSide?: "left" | "right" | "top" | "bottom"; + testID?: string; + nativeID?: string; +} + +const MOBILE_MENU_LINE_WIDTH = 16; +const MOBILE_MENU_LINE_SHORT_WIDTH = 8; +const MOBILE_MENU_LINE_HEIGHT = 1.5; + +function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) { + const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted; + const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]); + const shortLineStyle = useMemo( + () => [styles.mobileMenuLine, styles.mobileMenuLineShort, color], + [color], + ); + return ( + + + + + + ); +} + +function SidebarMenuToggleButton({ + isMobile, + extraMutedIdleIcon = false, + resolvedStyle, + tooltipSide = "right", + testID = "menu-button", + nativeID = "menu-button", +}: Omit & { + isMobile: boolean; + extraMutedIdleIcon?: boolean; + resolvedStyle: StyleProp; +}) { + const { t } = useTranslation(); + const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); + const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); + const toggleShortcutKeys = useMemo( + () => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]), + [], + ); + + const handlePress = useCallback(() => { + toggleAgentListForLayout({ isCompact: isMobile }); + }, [toggleAgentListForLayout, isMobile]); + + const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]); + + return ( + + {isMobile ? ( + + ) : ( + + )} + + ); +} + +export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const isMobile = useIsCompactFormFactor(); + const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); + const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + const placeholderStyle = useMemo( + () => [headerIconSlotStyle.slot, resolvedStyle], + [resolvedStyle], + ); + + if (!isMobile && !ownsTopLeft) { + return null; + } + + if (!isMobile && hasTopLeftWindowControls) { + return ( + + + + ); + } + + return ; +} + +export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + leadingToggle: { + marginLeft: { + xs: 0, + md: -theme.spacing[2], + }, + }, + mobileMenuIcon: { + width: MOBILE_MENU_LINE_WIDTH, + height: 12, + justifyContent: "space-between", + alignItems: "flex-start", + }, + desktopMenuIconSpace: { + width: theme.iconSize.md, + height: theme.iconSize.md, + }, + mobileMenuLine: { + width: MOBILE_MENU_LINE_WIDTH, + height: MOBILE_MENU_LINE_HEIGHT, + borderRadius: theme.borderRadius.full, + }, + mobileMenuLineMuted: { + backgroundColor: theme.colors.foregroundMuted, + }, + mobileMenuLineExtraMuted: { + backgroundColor: theme.colors.foregroundExtraMuted, + }, + mobileMenuLineShort: { + width: MOBILE_MENU_LINE_SHORT_WIDTH, + }, +})); diff --git a/packages/app/src/components/hosts/host-filter.tsx b/packages/app/src/components/hosts/host-filter.tsx index ffdb6a2130..d073dc943d 100644 --- a/packages/app/src/components/hosts/host-filter.tsx +++ b/packages/app/src/components/hosts/host-filter.tsx @@ -2,13 +2,13 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, Server } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { HostProfile } from "@/types/host-connection"; import type { Theme } from "@/styles/theme"; import { ALL_HOSTS_OPTION_ID, getHostPickerLabel, HostPicker, HostStatusDotSlot, + type HostPickerHost, } from "@/components/hosts/host-picker"; const ThemedServer = withUnistyles(Server); @@ -16,7 +16,7 @@ const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); export interface HostFilterProps { - hosts: HostProfile[]; + hosts: HostPickerHost[]; selectedHost: string; onSelectHost: (serverId: string) => void; /** @@ -29,9 +29,9 @@ export interface HostFilterProps { } /** - * The "All hosts / " filter pill shared by the History and Schedules - * screens: an anchored HostPicker with `includeAllHost`, hidden by the caller - * when only one host exists. Copies the History layout exactly. + * The "All hosts / " host pill shared by History, Schedules, Usage, the label manager and + * plugin screens: an anchored HostPicker, hidden by the caller when only one host exists. Without + * `includeAllHost` it picks the one host a surface shows. */ export function HostFilter({ hosts, diff --git a/packages/app/src/components/hosts/host-picker.tsx b/packages/app/src/components/hosts/host-picker.tsx index 9bc8955e64..ced0be3d55 100644 --- a/packages/app/src/components/hosts/host-picker.tsx +++ b/packages/app/src/components/hosts/host-picker.tsx @@ -24,7 +24,7 @@ export { const SEARCHABLE_THRESHOLD = 10; type RenderHostOption = NonNullable; -interface HostPickerHost { +export interface HostPickerHost { serverId: string; label: string; } diff --git a/packages/app/src/components/icons/manual-status-icon.tsx b/packages/app/src/components/icons/manual-status-icon.tsx new file mode 100644 index 0000000000..fcf6f6d547 --- /dev/null +++ b/packages/app/src/components/icons/manual-status-icon.tsx @@ -0,0 +1,16 @@ +import Svg, { Circle, Path } from "react-native-svg"; +import type { SvgPathIconProps } from "./svg-path-icon"; + +// Forge-neutral rendering of GitLab's manual-status gear, aligned with the +// proportions of the other pipeline status icons. +const MANUAL_STATUS_GEAR_PATH = + "M10.5 7.63V6.37l-.787-.13c-.044-.175-.132-.349-.263-.61l.481-.652-.918-.913-.657.478a2.346 2.346 0 0 0-.612-.26L7.656 3.5H6.388l-.132.783c-.219.043-.394.13-.612.26l-.657-.478-.918.913.437.652c-.131.218-.175.392-.262.61l-.744.086v1.261l.787.13c.044.218.132.392.263.61l-.438.651.92.913.655-.434c.175.086.394.173.613.26l.131.783h1.313l.131-.783c.219-.043.394-.13.613-.26l.656.478.918-.913-.48-.652c.13-.218.218-.435.262-.61l.656-.13zM7 8.283a1.285 1.285 0 0 1-1.313-1.305c0-.739.57-1.304 1.313-1.304.744 0 1.313.565 1.313 1.304 0 .74-.57 1.305-1.313 1.305z"; + +export function ManualStatusIcon({ size = 16, color = "currentColor" }: SvgPathIconProps) { + return ( + + + + + ); +} diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts index 784ad9d39d..785b18da7b 100644 --- a/packages/app/src/components/import-session-sheet-view-model.test.ts +++ b/packages/app/src/components/import-session-sheet-view-model.test.ts @@ -4,10 +4,16 @@ import { aggregateSessionEntries, ALL_FILTER_VALUE, buildProviderLabelMap, - collectErroredProviderLabels, + collectProviderErrorRows, computeEmptyState, + formatDirectoryLabel, getPromptPreview, getSessionTitle, + hasMoreSessions, + nextPageLimit, + PER_PROVIDER_LIMIT, + resolveDirectoryLabel, + resolveImportTarget, resolveProvidersToFetch, requiresImportSessionsHostUpgrade, type SessionsQueryResult, @@ -167,27 +173,176 @@ describe("sumFilteredAlreadyImportedCount", () => { }); }); -describe("collectErroredProviderLabels", () => { - it("returns no labels when no providers are being fetched", () => { - expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]); +describe("collectProviderErrorRows", () => { + it("returns no rows when no providers are being fetched", () => { + expect(collectProviderErrorRows(null, [], new Map())).toEqual([]); }); - it("returns labels for each errored provider, falling back to provider id", () => { - const labels = collectErroredProviderLabels( + it("returns a row for each provider whose request failed", () => { + const rows = collectProviderErrorRows( ["claude", "codex"], [settled(undefined, { isError: true }), settled({ entries: [] })], new Map([["claude", "Claude Code"]]), ); - expect(labels).toEqual(["Claude Code"]); + expect(rows).toEqual([{ provider: "claude", label: "Claude Code" }]); + }); + + it("returns a row for a provider the daemon reported as failed in a good response", () => { + const rows = collectProviderErrorRows( + ["codex"], + [ + settled({ + entries: [], + providerErrors: [{ provider: "codex", message: "timed out" }], + }), + ], + new Map([["codex", "Codex"]]), + ); + expect(rows).toEqual([{ provider: "codex", label: "Codex" }]); }); it("uses provider id when the label map has no entry", () => { - const labels = collectErroredProviderLabels( + const rows = collectProviderErrorRows( ["codex"], [settled(undefined, { isError: true })], new Map(), ); - expect(labels).toEqual(["codex"]); + expect(rows).toEqual([{ provider: "codex", label: "codex" }]); + }); +}); + +describe("nextPageLimit", () => { + it("grows the per-provider limit one step at a time", () => { + expect(nextPageLimit(PER_PROVIDER_LIMIT)).toBe(45); + expect(nextPageLimit(45)).toBe(90); + expect(nextPageLimit(90)).toBe(200); + }); + + it("stops at the protocol ceiling", () => { + expect(nextPageLimit(200)).toBe(200); + }); +}); + +describe("hasMoreSessions", () => { + it("reports more when a provider filled the requested page", () => { + expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 2)).toBe(true); + }); + + it("reports no more once every provider returned a short page", () => { + expect(hasMoreSessions([settled({ entries: [entry()] })], 2)).toBe(false); + }); + + it("reports no more at the last page size, however full the page is", () => { + expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 200)).toBe(false); + }); + + it("keeps reporting more while the next page replaces placeholder rows", () => { + expect( + hasMoreSessions([settled({ entries: [entry()] }, { isPlaceholderData: true })], 45), + ).toBe(true); + }); +}); + +describe("resolveDirectoryLabel", () => { + const projects = [ + { rootPath: "/home/me/paseo", name: "paseo" }, + { rootPath: "/home/me/paseo/packages/app", name: "paseo app" }, + ]; + + it("names the project root after the project alone", () => { + expect(resolveDirectoryLabel("/home/me/paseo", projects)).toEqual({ name: "paseo" }); + }); + + it("qualifies a worktree under the project with its path below the root", () => { + expect( + resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]), + ).toEqual({ name: "paseo", detail: ".dev/worktrees/abc/zebra" }); + }); + + it("tells two worktrees of the same project apart", () => { + const zebra = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]); + const otter = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/def/otter", [projects[0]!]); + expect(zebra).not.toEqual(otter); + expect([zebra.detail, otter.detail]).toEqual([ + ".dev/worktrees/abc/zebra", + ".dev/worktrees/def/otter", + ]); + }); + + it("picks the most specific project root containing the directory", () => { + expect(resolveDirectoryLabel("/home/me/paseo/packages/app/src", projects)).toEqual({ + name: "paseo app", + detail: "src", + }); + }); + + it("ignores trailing slashes on both sides", () => { + expect( + resolveDirectoryLabel("/home/me/paseo/", [{ rootPath: "/home/me/paseo/", name: "p" }]), + ).toEqual({ name: "p" }); + }); + + it("does not match a project root that is only a string prefix", () => { + expect(resolveDirectoryLabel("/home/me/paseo-fork", projects)).toEqual({ + name: "/home/me/paseo-fork", + }); + }); + + it("falls back to the path when no project owns the directory", () => { + expect(resolveDirectoryLabel("/tmp/scratch", projects)).toEqual({ name: "/tmp/scratch" }); + }); +}); + +describe("formatDirectoryLabel", () => { + it("shows the project name alone at its root", () => { + expect(formatDirectoryLabel({ name: "paseo" })).toBe("paseo"); + }); + + it("appends the path under the root so worktrees of one project read apart", () => { + expect(formatDirectoryLabel({ name: "paseo", detail: ".dev/worktrees/zebra" })).toBe( + "paseo · .dev/worktrees/zebra", + ); + }); +}); + +describe("resolveImportTarget", () => { + it("trusts a scoped listing, whose rows the daemon already matched realpath-aware", () => { + expect( + resolveImportTarget({ + entryCwd: "/private/repo/paseo", + workspaceCwd: "/repo/paseo", + workspaceId: "ws-1", + isScopedListing: true, + }), + ).toEqual({ workspaceId: "ws-1", crossWorkspace: false }); + }); + + it("keeps the current workspace for a Show-all row in that workspace's directory", () => { + expect( + resolveImportTarget({ + entryCwd: "/repo/paseo/", + workspaceCwd: "/repo/paseo", + workspaceId: "ws-1", + isScopedListing: false, + }), + ).toEqual({ workspaceId: "ws-1", crossWorkspace: false }); + }); + + it("drops the workspace for a Show-all row from another directory, which the daemon rejects", () => { + expect( + resolveImportTarget({ + entryCwd: "/repo/other", + workspaceCwd: "/repo/paseo", + workspaceId: "ws-1", + isScopedListing: false, + }), + ).toEqual({ crossWorkspace: true }); + }); + + it("treats a sheet with no workspace as cross-workspace", () => { + expect(resolveImportTarget({ entryCwd: "/repo/paseo", isScopedListing: false })).toEqual({ + crossWorkspace: true, + }); }); }); @@ -236,6 +391,7 @@ describe("computeEmptyState", () => { isQueryingProviders: true, allQueriesSettled: true, selectedProvider: ALL_FILTER_VALUE, + hasQuery: false, aggregatedCount: 0, visibleCount: 0, totalAlreadyImportedCount: 0, @@ -274,6 +430,15 @@ describe("computeEmptyState", () => { }); }); + it("says nothing matched when a query narrowed the list to nothing", () => { + const result = computeEmptyState({ + ...baseInputs, + hasQuery: true, + totalAlreadyImportedCount: 4, + }); + expect(result.emptyStateTitle).toBe("No sessions match your search."); + }); + it("shows the already-imported message when imported entries were filtered out", () => { const result = computeEmptyState({ ...baseInputs, diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts index ec6cd78f9a..d9f31944c1 100644 --- a/packages/app/src/components/import-session-sheet-view-model.ts +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -5,6 +5,18 @@ import { i18n } from "@/i18n/i18next"; export const PER_PROVIDER_LIMIT = 15; export const ALL_FILTER_VALUE = "__all__"; +/** + * Paging grows the per-provider limit instead of carrying an offset. The daemon + * ranks the whole candidate set on every request, so an offset into a previous + * ranking would drift as sessions move; refetching a larger page cannot. 200 is + * the protocol ceiling on `limit`. + */ +const PAGE_LIMITS: readonly number[] = [PER_PROVIDER_LIMIT, 45, 90, 200]; + +export function nextPageLimit(limit: number): number { + return PAGE_LIMITS.find((candidate) => candidate > limit) ?? limit; +} + export function requiresImportSessionsHostUpgrade(input: { supportsSnapshot: boolean; workspaceId?: string | null; @@ -18,11 +30,13 @@ export interface SessionsQueryResult { | { entries: FetchRecentProviderSessionEntry[]; filteredAlreadyImportedCount?: number; + providerErrors?: Array<{ provider: string; message: string }>; } | undefined; isError: boolean; isLoading: boolean; isPending: boolean; + isPlaceholderData?: boolean; } export function resolveProvidersToFetch( @@ -81,20 +95,132 @@ export function sumFilteredAlreadyImportedCount( return total; } -export function collectErroredProviderLabels( +/** + * A provider that returned a full page may have more behind it. The daemon + * slices to `limit` after dropping already-imported sessions, so a short page is + * the end of that provider's list. Placeholder rows belong to the previous page, + * so the button holds its place instead of blinking out while the next loads. + */ +export function hasMoreSessions( + queries: ReadonlyArray, + limit: number, +): boolean { + if (nextPageLimit(limit) === limit) return false; + return queries.some((entryQuery) => { + const count = entryQuery.data?.entries.length ?? 0; + return entryQuery.isPlaceholderData ? count > 0 : count >= limit; + }); +} + +export interface ProviderErrorRow { + provider: string; + label: string; +} + +/** + * One row per provider that could not be listed, whether the request itself + * failed or the daemon reported the provider as failed in an otherwise good + * response. Both mean the same thing to the user, and both retry the same way. + */ +export function collectProviderErrorRows( providersToFetch: AgentProvider[] | null, queries: ReadonlyArray, providerLabelById: ReadonlyMap, -): string[] { +): ProviderErrorRow[] { if (providersToFetch === null) return []; - const labels: string[] = []; - for (let index = 0; index < queries.length; index++) { - if (queries[index]?.isError) { - const provider = providersToFetch[index]; - labels.push(providerLabelById.get(provider) ?? provider); - } + const rows: ProviderErrorRow[] = []; + for (let index = 0; index < providersToFetch.length; index++) { + const query = queries[index]; + const provider = providersToFetch[index]; + if (!query || provider === undefined) continue; + const failed = query.isError || (query.data?.providerErrors?.length ?? 0) > 0; + if (!failed) continue; + rows.push({ provider, label: providerLabelById.get(provider) ?? provider }); } - return labels; + return rows; +} + +export interface DirectoryProject { + rootPath: string; + name: string; +} + +export interface DirectoryLabel { + /** The owning project's name, or the whole path when no project owns it. */ + name: string; + /** + * The directory's path under its project root, absent at the root itself. + * Worktrees of one project all resolve to the same name, so without this the + * sheet would show several identical headings. + */ + detail?: string; +} + +function withoutTrailingSlash(path: string): string { + return path.length > 1 ? path.replace(/\/+$/, "") : path; +} + +/** The project's name when the app knows the directory, else the directory itself. */ +export function resolveDirectoryLabel( + directory: string, + projects: ReadonlyArray, +): DirectoryLabel { + const normalized = withoutTrailingSlash(directory); + let bestRoot: string | null = null; + let bestName: string | null = null; + for (const project of projects) { + const root = withoutTrailingSlash(project.rootPath); + if (!root) continue; + if (bestRoot !== null && root.length <= bestRoot.length) continue; + if (normalized !== root && !normalized.startsWith(`${root}/`)) continue; + bestRoot = root; + bestName = project.name; + } + if (bestRoot === null || bestName === null) { + return { name: normalized }; + } + const relative = normalized.slice(bestRoot.length + 1); + return relative ? { name: bestName, detail: relative } : { name: bestName }; +} + +/** The one-line form a row shows under its prompt preview. */ +export function formatDirectoryLabel(label: DirectoryLabel): string { + return label.detail ? `${label.name} · ${label.detail}` : label.name; +} + +export interface ImportTarget { + /** + * Omitted unless the row's directory is the scoped workspace's own: the daemon + * rejects an import whose cwd does not match the requested workspace. + */ + workspaceId?: string; + /** + * The agent lands in a workspace other than the one the sheet was opened from, + * so the caller has to open that workspace instead of adding a tab here. + */ + crossWorkspace: boolean; +} + +export function resolveImportTarget(input: { + entryCwd: string; + /** The directory the sheet was opened for, absent when it was opened host-wide. */ + workspaceCwd?: string | null; + workspaceId?: string | null; + /** The listed rows came from a fetch scoped to `workspaceCwd`. */ + isScopedListing: boolean; +}): ImportTarget { + if (!input.workspaceId || !input.workspaceCwd) { + return { crossWorkspace: true }; + } + // A scoped listing only holds rows the daemon already matched to this + // workspace's directory with realpaths resolved, which the client cannot do. + // "Show all" drops that guarantee, so each row is compared by path instead. + const belongsToWorkspace = + input.isScopedListing || + withoutTrailingSlash(input.entryCwd) === withoutTrailingSlash(input.workspaceCwd); + return belongsToWorkspace + ? { workspaceId: input.workspaceId, crossWorkspace: false } + : { crossWorkspace: true }; } export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { @@ -123,6 +249,7 @@ export interface EmptyStateInputs { isQueryingProviders: boolean; allQueriesSettled: boolean; selectedProvider: string; + hasQuery: boolean; aggregatedCount: number; visibleCount: number; totalAlreadyImportedCount: number; @@ -142,6 +269,11 @@ export function computeEmptyState(input: EmptyStateInputs): { if (!showEmptyState) { return { showEmptyState, emptyStateTitle: "" }; } + // A query narrowing the list to nothing is a different answer from a host with + // nothing to import, and the recovery is different too. + if (input.hasQuery) { + return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noMatches") }; + } const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; if (isFilteredEmpty) { const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; diff --git a/packages/app/src/components/import-session-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx index 0f2820ba76..35c475e824 100644 --- a/packages/app/src/components/import-session-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -14,12 +14,12 @@ import { ImportSessionSheet } from "@/components/import-session-sheet"; const { theme } = vi.hoisted(() => ({ theme: { - spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 }, + spacing: { 1: 4, 1.5: 6, 2: 8, 2.5: 10, 3: 12, 4: 16, 6: 24, 8: 32 }, borderWidth: { 1: 1 }, borderRadius: { md: 6, lg: 8, full: 9999 }, fontSize: { xs: 11, sm: 13, base: 15 }, fontWeight: { normal: "400", medium: "500", semibold: "600" }, - iconSize: { sm: 14, md: 16 }, + iconSize: { sm: 14, md: 16, lg: 24 }, opacity: { 50: 0.5 }, colors: { foreground: "#fff", @@ -30,6 +30,8 @@ const { theme } = vi.hoisted(() => ({ surface3: "#333", border: "#444", borderAccent: "#555", + interactionHighlight: "rgba(255,255,255,0.08)", + palette: { red: { 300: "#f87171" } }, }, }, })); @@ -61,7 +63,8 @@ vi.mock("@/constants/layout", () => ({ })); vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: () => () => null, + useProviderIcon: () => () => null, + useProviderIcons: () => () => () => null, })); vi.mock("lucide-react-native", () => { @@ -75,6 +78,8 @@ vi.mock("lucide-react-native", () => { Inbox: icon("Inbox"), Layers: icon("Layers"), RotateCw: icon("RotateCw"), + Search: icon("Search"), + X: icon("X"), }; }); @@ -117,6 +122,22 @@ vi.mock("@/components/ui/combobox", () => ({ ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label), })); +interface SheetSearchProps { + onChange: (value: string) => void; + placeholder?: string; + testID?: string; +} + +function SheetSearchInput({ search }: { search: SheetSearchProps }) { + const handleChange = React.useCallback( + (event: React.ChangeEvent) => search.onChange(event.target.value), + [search], + ); + return ( + + ); +} + vi.mock("@/components/adaptive-modal-sheet", () => ({ AdaptiveModalSheet: ({ visible, @@ -125,14 +146,21 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({ testID, }: { visible: boolean; - header?: { title: string; actions?: ReactNode }; + header?: { + title: string; + subtitle?: ReactNode; + actions?: ReactNode; + search?: { onChange: (value: string) => void; placeholder?: string; testID?: string }; + }; children: ReactNode; testID?: string; }) => visible ? (

{header?.title}

+ {header?.subtitle} {header?.actions} + {header?.search ? : null} {children}
) : null, @@ -163,26 +191,62 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({ }), })); +const mockHostFeatures = vi.hoisted(() => ({ + current: { importSessionSearch: true, importSessionWorkspaceTarget: true } as Record< + string, + boolean + >, +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: (_serverId: string | null, feature: string) => + mockHostFeatures.current[feature] === true, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHosts: () => [{ serverId: "server-1", label: "workbench" }], +})); + +const mockHostProjects = vi.hoisted(() => ({ + current: [] as Array<{ iconWorkingDir: string; projectName: string }>, +})); + +vi.mock("@/projects/host-projects", () => ({ + useHostProjects: () => mockHostProjects.current, +})); + interface RenderOptions { visible?: boolean; onClose?: () => void; onImportedAgent?: (agentId: string) => void; onImported?: (agent: Awaited>) => void; cwd?: string | null; + workspaceId?: string; + supportsSearch?: boolean; + projects?: Array<{ iconWorkingDir: string; projectName: string }>; snapshot?: { entries?: ProviderSnapshotEntry[]; supportsSnapshot?: boolean; }; } -function renderSheet( - client: Pick, - options?: RenderOptions, -) { +function applyHostMocks(options?: RenderOptions) { mockSnapshot.current = { entries: options?.snapshot?.entries, supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false, }; + mockHostFeatures.current = { + importSessionSearch: options?.supportsSearch ?? true, + importSessionWorkspaceTarget: true, + }; + mockHostProjects.current = options?.projects ?? []; +} + +function renderSheet( + client: Pick, + options?: RenderOptions, +) { + applyHostMocks(options); const queryClient = new QueryClient({ defaultOptions: { @@ -200,6 +264,7 @@ function renderSheet( client={client} serverId="server-1" cwd={cwd} + workspaceId={options?.workspaceId} onClose={options?.onClose ?? vi.fn()} onImportedAgent={options?.onImportedAgent ?? vi.fn()} onImported={options?.onImported} @@ -245,6 +310,16 @@ function createImportedAgentSnapshot(id: string): Awaited + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: `thread-${index}`, + title: `Session ${index}`, + }), + ); +} + function createProviderSessionEntry( overrides?: Partial, ): FetchRecentProviderSessionEntry { @@ -377,7 +452,8 @@ describe("ImportSessionSheet", () => { }, ); - await screen.findByText("Could not load recent sessions."); + await screen.findByText("Could not load Claude Code sessions"); + screen.getByTestId("import-session-retry-claude"); }); it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => { @@ -478,19 +554,34 @@ describe("ImportSessionSheet", () => { }); it("imports a selected session by provider handle and reports the imported agent", async () => { - const fetchRecentProviderSessions = vi.fn(async () => ({ - requestId: "recent-provider-sessions", - entries: [ - createProviderSessionEntry({ - providerId: "claude", - providerLabel: "Claude Code", - cwd: "/repo/paseo-realpath", + const events: string[] = []; + let fetchCount = 0; + const fetchRecentProviderSessions = vi.fn(async () => { + fetchCount += 1; + events.push("fetch"); + if (fetchCount > 1) { + return await new Promise(() => {}); + } + return { + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + cwd: "/repo/paseo-realpath", + }), + ], + }; + }); + let resolveImport!: (agent: ReturnType) => void; + const importAgent = vi.fn( + () => + new Promise>((resolve) => { + resolveImport = resolve; }), - ], - })); - const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); - const onClose = vi.fn(); - const onImportedAgent = vi.fn(); + ); + const onClose = vi.fn(() => events.push("close")); + const onImportedAgent = vi.fn(() => events.push("navigate")); renderSheet( { fetchRecentProviderSessions, importAgent } as Pick< @@ -500,21 +591,31 @@ describe("ImportSessionSheet", () => { { onClose, onImportedAgent, + workspaceId: "ws-current", snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, }, ); - fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1")); + const row = await screen.findByTestId("import-session-session-claude-provider-thread-1"); + expect(events).toEqual(["fetch"]); + fireEvent.click(row); + + await screen.findByText("Importing..."); + expect(events).toEqual(["fetch"]); + resolveImport(createImportedAgentSnapshot("agent-imported")); await waitFor(() => { expect(importAgent).toHaveBeenCalledWith({ providerId: "claude", providerHandleId: "provider-thread-1", cwd: "/repo/paseo-realpath", + workspaceId: "ws-current", }); + expect(events).toEqual(["fetch", "close", "navigate"]); }); expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); expect(onClose).toHaveBeenCalledTimes(1); + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1); }); it("shows an import error state without closing when selected session import fails", async () => { @@ -649,7 +750,7 @@ describe("ImportSessionSheet", () => { ); await screen.findByText("Session codex"); - await screen.findByText("Could not load sessions for Claude Code."); + await screen.findByText("Could not load Claude Code sessions"); }); it("filters the merged list when a provider badge is selected and restores it on All", async () => { @@ -758,7 +859,7 @@ describe("ImportSessionSheet", () => { expect(fetchRecentProviderSessions).not.toHaveBeenCalled(); }); - it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => { + it("omits cwd from fetch and names each row's folder when cwd is unset", async () => { const fetchRecentProviderSessions = vi.fn(async () => ({ requestId: "recent-provider-sessions", entries: [ @@ -793,6 +894,9 @@ describe("ImportSessionSheet", () => { expect.objectContaining({ cwd: expect.anything() }), ); await screen.findByText("/home/me/work/other-project"); + expect( + screen.getByTestId("import-session-row-folder-claude-provider-thread-1").textContent, + ).toBe("/home/me/work/other-project"); }); it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => { @@ -836,10 +940,344 @@ describe("ImportSessionSheet", () => { }); expect(onImported).toHaveBeenCalledTimes(1); expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" })); - expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); + expect(onImportedAgent).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); + it("names the scope after the host when unscoped and after the workspace when scoped", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent); + + renderSheet(client, { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }); + await screen.findByText("Sessions on workbench"); + expect(screen.queryByTestId("import-session-show-all")).toBeNull(); + cleanup(); + + renderSheet(client, { + workspaceId: "ws-current", + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }); + await screen.findByText("This workspace"); + screen.getByTestId("import-session-show-all"); + }); + + it("lists unscoped rows newest first and names each row's folder", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "newest", + cwd: "/home/me/paseo", + title: "Newest in paseo", + lastActivityAt: "2026-04-30T12:00:00.000Z", + }), + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "elsewhere", + cwd: "/tmp/scratch", + title: "Scratch session", + lastActivityAt: "2026-04-30T11:00:00.000Z", + }), + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "older", + cwd: "/home/me/paseo", + title: "Older in paseo", + lastActivityAt: "2026-04-30T10:00:00.000Z", + }), + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "worktree", + cwd: "/home/me/paseo/.dev/worktrees/zebra", + title: "Worktree session", + lastActivityAt: "2026-04-30T09:00:00.000Z", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + projects: [{ iconWorkingDir: "/home/me/paseo", projectName: "paseo" }], + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByTestId("import-session-session-claude-newest"); + expect( + screen + .getAllByTestId(/^import-session-session-/) + .map((row) => row.getAttribute("data-testid")), + ).toEqual([ + "import-session-session-claude-newest", + "import-session-session-claude-elsewhere", + "import-session-session-claude-older", + "import-session-session-claude-worktree", + ]); + expect( + screen.getAllByTestId(/^import-session-row-folder-/).map((folder) => folder.textContent), + ).toEqual(["paseo", "/tmp/scratch", "paseo", "paseo · .dev/worktrees/zebra"]); + }); + + it("leaves the folder off every row when the sheet is scoped to one workspace", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "scoped", + cwd: "/repo/paseo", + title: "Scoped session", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + workspaceId: "ws-current", + projects: [{ iconWorkingDir: "/repo/paseo", projectName: "paseo" }], + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("Scoped session"); + expect(screen.queryAllByTestId(/^import-session-row-folder-/)).toHaveLength(0); + }); + + it("sends the debounced search query to every provider", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalled(); + }); + + fireEvent.change(screen.getByTestId("import-session-search"), { + target: { value: "invoice" }, + }); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 15, + query: "invoice", + }); + }); + }); + + it("hides the search field when the host cannot search sessions", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + supportsSearch: false, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("import-session-search")).toBeNull(); + }); + + it("asks for a larger page when Load more is pressed, and stops offering it on a short page", async () => { + const fetchRecentProviderSessions = vi.fn(async (options: { limit?: number } | undefined) => ({ + requestId: "recent-provider-sessions", + entries: createPageOfEntries(options?.limit === 15 ? 15 : 20), + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + fireEvent.click(await screen.findByTestId("import-session-load-more")); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 45, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("import-session-load-more")).toBeNull(); + }); + }); + + it("stops the refresh spinner and retries only the failed provider", async () => { + const fetchRecentProviderSessions = vi.fn( + async (options: { providers?: string[] } | undefined) => { + const provider = options?.providers?.[0]; + if (provider === "claude") { + throw new Error("claude offline"); + } + return { + requestId: `recent-${provider}`, + entries: [ + createProviderSessionEntry({ + providerId: provider ?? "codex", + providerHandleId: `${provider}-thread`, + title: `Session ${provider}`, + }), + ], + }; + }, + ); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")], + }, + }, + ); + + await screen.findByText("Session codex"); + await screen.findByText("Could not load Claude Code sessions"); + await waitFor(() => { + expect(screen.queryByTestId("import-session-loading-spinner")).toBeNull(); + }); + + fetchRecentProviderSessions.mockClear(); + fireEvent.click(screen.getByTestId("import-session-retry-claude")); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + cwd: "/repo/paseo", + providers: ["claude"], + limit: 15, + }); + }); + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1); + }); + + it("shows an error row for a provider the daemon reported as failed", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })], + providerErrors: [{ provider: "codex", message: "timed out" }], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("codex")] }, + }, + ); + + await screen.findByText("Could not load Codex sessions"); + }); + + it("imports a foreign-directory row without the current workspace once Show all is on", async () => { + const fetchRecentProviderSessions = vi.fn(async (options: { cwd?: string } | undefined) => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: options?.cwd ? "scoped-thread" : "foreign-thread", + cwd: options?.cwd ?? "/home/me/work/other-project", + title: options?.cwd ? "Scoped session" : "Foreign session", + }), + ], + })); + const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); + const onImported = vi.fn(); + const onImportedAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + workspaceId: "ws-current", + onImported, + onImportedAgent, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("Scoped session"); + fireEvent.click(screen.getByTestId("import-session-show-all")); + + await screen.findByText("Foreign session"); + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 15, + }); + + fireEvent.click(screen.getByTestId("import-session-session-claude-foreign-thread")); + + await waitFor(() => { + expect(importAgent).toHaveBeenCalledWith({ + providerId: "claude", + providerHandleId: "foreign-thread", + cwd: "/home/me/work/other-project", + }); + }); + expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" })); + expect(onImportedAgent).not.toHaveBeenCalled(); + }); + it("refetches sessions when the refresh button is clicked", async () => { const fetchRecentProviderSessions = vi.fn(async () => ({ requestId: "recent-provider-sessions", diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 21b81b27d2..3be15c0892 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; -import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { DaemonClient, @@ -10,22 +10,33 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { getProviderIcon } from "@/components/provider-icons"; -import { formatTimeAgo } from "@/utils/time"; +import { useProviderIcon, useProviderIcons } from "@/components/provider-icons"; +import { useTimeAgo } from "@/hooks/use-time-ago"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { useHostProjects } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; +import { useHosts } from "@/runtime/host-runtime"; import { i18n } from "@/i18n/i18next"; import { aggregateSessionEntries, ALL_FILTER_VALUE, buildProviderLabelMap, - collectErroredProviderLabels, + collectProviderErrorRows, computeEmptyState, + type DirectoryProject, + formatDirectoryLabel, getPromptPreview, getSessionTitle, + hasMoreSessions, + resolveDirectoryLabel, + nextPageLimit, PER_PROVIDER_LIMIT, + type ProviderErrorRow, + resolveImportTarget, resolveProvidersToFetch, requiresImportSessionsHostUpgrade, sumFilteredAlreadyImportedCount, @@ -33,6 +44,8 @@ import { const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; +/** Long enough that a typed word is one request, short enough to feel live. */ +const SEARCH_DEBOUNCE_MS = 200; type RecentProviderSessionsClient = Pick< DaemonClient, @@ -48,7 +61,9 @@ interface ImportSessionSheetProps { cwd?: string | null; workspaceId?: string | null; onClose: () => void; + /** The agent belongs to the workspace the sheet was opened from; open it here. */ onImportedAgent?: (agentId: string) => void; + /** The agent belongs to its own workspace; open that workspace and navigate. */ onImported?: (agent: ImportedAgent) => void; } @@ -56,27 +71,51 @@ type RecentSessionsResponse = Awaited< ReturnType >; +type SessionsQueryKey = ReadonlyArray; + +function buildSessionsQueryKey(input: { + cwd: string | null; + query: string; + limit: number; + provider?: string; +}): SessionsQueryKey { + return [ + "recent-provider-sessions", + input.cwd, + input.query, + input.limit, + ...(input.provider === undefined ? [] : [input.provider]), + ]; +} + interface SessionsQueryConfig { - queryKey: ReadonlyArray; + queryKey: SessionsQueryKey; enabled: boolean; + // A provider that cannot answer stays broken until the user asks again. React + // Query's default retry would keep the header spinner turning and pile up + // in-flight daemon requests behind a dead provider (#2512). + retry: false; + placeholderData: typeof keepPreviousData; queryFn: () => Promise; } function buildSessionsQueriesConfig(args: { providersToFetch: AgentProvider[] | null; - sessionsQueryRoot: ReadonlyArray; visible: boolean; client: RecentProviderSessionsClient | null; - cwd: string | null | undefined; + cwd: string | null; + query: string; + limit: number; hostDisconnectedMessage?: string; }): SessionsQueryConfig[] { - const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } = - args; + const { providersToFetch, visible, client, cwd, query, limit, hostDisconnectedMessage } = args; if (providersToFetch === null) return []; const enabled = visible && Boolean(client); return providersToFetch.map((provider) => ({ - queryKey: [...sessionsQueryRoot, provider], + queryKey: buildSessionsQueryKey({ cwd, query, limit, provider }), enabled, + retry: false as const, + placeholderData: keepPreviousData, queryFn: async () => { if (!client) { throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); @@ -84,7 +123,8 @@ function buildSessionsQueriesConfig(args: { return await client.fetchRecentProviderSessions({ ...(cwd ? { cwd } : {}), providers: [provider], - limit: PER_PROVIDER_LIMIT, + limit, + ...(query ? { query } : {}), }); }, })); @@ -96,8 +136,6 @@ interface SheetStatusMessagesProps { hasNoImportableProviders: boolean; isLoadingSessions: boolean; hasRows: boolean; - allQueriesErrored: boolean; - erroredProviderLabels: ReadonlyArray; importErrored: boolean; } @@ -107,8 +145,6 @@ function SheetStatusMessages({ hasNoImportableProviders, isLoadingSessions, hasRows, - allQueriesErrored, - erroredProviderLabels, importErrored, }: SheetStatusMessagesProps) { const { theme } = useUnistyles(); @@ -130,16 +166,6 @@ function SheetStatusMessages({ {t("importSession.status.loading")}
) : null} - {allQueriesErrored ? ( - {t("importSession.status.failedAll")} - ) : null} - {!allQueriesErrored && erroredProviderLabels.length > 0 ? ( - - {t("importSession.status.failedProviders", { - providers: erroredProviderLabels.join(", "), - })} - - ) : null} {importErrored ? ( {t("importSession.status.failedImport")} ) : null} @@ -147,6 +173,48 @@ function SheetStatusMessages({ ); } +function ProviderErrorBanner({ + rows, + onRetry, +}: { + rows: ReadonlyArray; + onRetry: (provider: string) => void; +}) { + return ( + + {rows.map((row) => ( + + ))} + + ); +} + +function ProviderErrorBannerRow({ + row, + onRetry, +}: { + row: ProviderErrorRow; + onRetry: (provider: string) => void; +}) { + const { t } = useTranslation(); + const handleRetry = useCallback(() => onRetry(row.provider), [onRetry, row.provider]); + return ( + + + {t("importSession.status.failedProvider", { provider: row.label })} + + + + ); +} + function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -177,6 +245,38 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre ); } +function ScopeSubtitle({ + hostLabel, + isScoped, + onShowAll, +}: { + hostLabel: string; + isScoped: boolean; + onShowAll: () => void; +}) { + const { t } = useTranslation(); + return ( + + + {isScoped + ? t("importSession.scope.workspace") + : t("importSession.scope.host", { host: hostLabel })} + + {isScoped ? ( + + ) : null} + + ); +} + function SheetEmptyState({ title }: { title: string }) { const { theme } = useUnistyles(); return ( @@ -190,24 +290,26 @@ function SheetEmptyState({ title }: { title: string }) { } function ImportSessionSheetRow({ + serverId, entry, disabled, importing, - showCwd, + folder, onImportSession, }: { + serverId: string | null; entry: FetchRecentProviderSessionEntry; disabled: boolean; importing: boolean; - showCwd: boolean; + /** The row's directory, shown only when rows can come from more than one. */ + folder: string | null; onImportSession: (entry: FetchRecentProviderSessionEntry) => void; }) { const { theme } = useUnistyles(); const { t } = useTranslation(); const title = getSessionTitle(entry); const promptPreview = getPromptPreview(entry); - const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); - const ProviderIcon = getProviderIcon(entry.providerId); + const ProviderIcon = useProviderIcon(entry.providerId, serverId); const accessibilityState = useMemo( () => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined), [disabled], @@ -241,16 +343,22 @@ function ImportSessionSheetRow({ {title} - - {importing ? t("importSession.row.importing") : lastActivity} - + {importing ? ( + {t("importSession.row.importing")} + ) : ( + + )}
{promptPreview} - {showCwd && entry.cwd ? ( - - {entry.cwd} + {folder ? ( + + {folder} ) : null}
@@ -258,6 +366,43 @@ function ImportSessionSheetRow({ ); } +function ImportSessionActivityTime({ date }: { date: string }) { + const label = useTimeAgo(new Date(date)); + return {label}; +} + +function SessionRows({ + serverId, + entries, + disabled, + importingSessionKey, + resolveFolder, + onImportSession, +}: { + serverId: string | null; + entries: ReadonlyArray; + disabled: boolean; + importingSessionKey: string | null; + resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null; + onImportSession: (entry: FetchRecentProviderSessionEntry) => void; +}) { + return ( + + {entries.map((entry) => ( + + ))} + + ); +} + export function ImportSessionSheet({ visible, client, @@ -272,8 +417,32 @@ export function ImportSessionSheet({ const queryClient = useQueryClient(); const { theme } = useUnistyles(); + // "Show all" widens a workspace-scoped sheet to the whole host. The sheet's own + // `cwd` stays the scope it was opened with, because that is what decides where + // an imported agent lands. + const [isShowingAllDirectories, setIsShowingAllDirectories] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [pageLimit, setPageLimit] = useState(PER_PROVIDER_LIMIT); + const [selectedProvider, setSelectedProvider] = useState(ALL_FILTER_VALUE); + + const scopeCwd = isShowingAllDirectories ? null : (cwd ?? null); + const supportsSearch = useHostFeature(serverId, "importSessionSearch"); + const query = useDebouncedValue(supportsSearch ? searchInput : "", SEARCH_DEBOUNCE_MS).trim(); + + useEffect(() => { + if (visible) return; + setIsShowingAllDirectories(false); + setSearchInput(""); + }, [visible]); + + // A narrower or wider list starts at page one; keeping a grown limit would + // fetch 200 rows for a query that matches three. + useEffect(() => { + setPageLimit(PER_PROVIDER_LIMIT); + }, [query, scopeCwd]); + const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { - cwd, + cwd: scopeCwd, enabled: visible, }); const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget"); @@ -293,22 +462,20 @@ export function ImportSessionSheet({ [snapshotEntries], ); - const sessionsQueryRoot = useMemo( - () => ["recent-provider-sessions", cwd ?? null] as const, - [cwd], - ); + const sessionsQueryRoot = useMemo(() => ["recent-provider-sessions", scopeCwd], [scopeCwd]); const queriesConfig = useMemo( () => buildSessionsQueriesConfig({ providersToFetch, - sessionsQueryRoot, visible, client, - cwd, + cwd: scopeCwd, + query, + limit: pageLimit, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [providersToFetch, sessionsQueryRoot, visible, client, cwd, t], + [providersToFetch, visible, client, scopeCwd, query, pageLimit, t], ); const queries = useQueries({ queries: queriesConfig }); @@ -320,8 +487,6 @@ export function ImportSessionSheet({ ); const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); - - const [selectedProvider, setSelectedProvider] = useState(ALL_FILTER_VALUE); const [isFilterOpen, setIsFilterOpen] = useState(false); const filterAnchorRef = useRef(null); @@ -339,6 +504,27 @@ export function ImportSessionSheet({ return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider); }, [aggregatedEntries, selectedProvider]); + const projectServerIds = useMemo(() => (serverId ? [serverId] : []), [serverId]); + const hostProjects = useHostProjects(projectServerIds); + const directoryProjects = useMemo( + () => + hostProjects.map((project) => ({ + rootPath: project.iconWorkingDir, + name: project.projectName, + })), + [hostProjects], + ); + + // A scoped sheet only lists one directory, so naming it on every row is noise. + const showRowFolders = scopeCwd === null; + const resolveFolder = useCallback( + (entry: FetchRecentProviderSessionEntry) => + showRowFolders + ? formatDirectoryLabel(resolveDirectoryLabel(entry.cwd, directoryProjects)) + : null, + [showRowFolders, directoryProjects], + ); + const filterComboboxOptions = useMemo( () => [ { id: ALL_FILTER_VALUE, label: t("importSession.filters.all") }, @@ -373,6 +559,8 @@ export function ImportSessionSheet({ setIsFilterOpen(false); }, []); + const getProviderIcon = useProviderIcons(serverId); + const SelectedProviderIcon = useProviderIcon(selectedProvider, serverId); const filterOptionIcons = useMemo(() => { const map = new Map(); map.set(ALL_FILTER_VALUE, ); @@ -381,7 +569,7 @@ export function ImportSessionSheet({ map.set(provider, ); } return map; - }, [filterProviders, theme.colors.foregroundMuted]); + }, [filterProviders, getProviderIcon, theme.colors.foregroundMuted]); const renderFilterOption = useCallback( ({ @@ -414,19 +602,31 @@ export function ImportSessionSheet({ if (!entry.cwd) { throw new Error("Session is missing a working directory"); } + const target = resolveImportTarget({ + entryCwd: entry.cwd, + workspaceCwd: cwd, + workspaceId, + isScopedListing: scopeCwd !== null, + }); const agent = await client.importAgent({ providerId: entry.providerId, providerHandleId: entry.providerHandleId, cwd: entry.cwd, - ...(workspaceId ? { workspaceId } : {}), + ...(target.workspaceId ? { workspaceId: target.workspaceId } : {}), }); - return agent; + return { agent, target }; }, - onSuccess: async (agent) => { - await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); + onSuccess: ({ agent, target }) => { onClose(); - onImportedAgent?.(agent.id); - onImported?.(agent); + if (target.crossWorkspace) { + onImported?.(agent); + } else { + onImportedAgent?.(agent.id); + } + void queryClient.invalidateQueries({ + queryKey: sessionsQueryRoot, + refetchType: "none", + }); }, }); @@ -442,23 +642,61 @@ export function ImportSessionSheet({ [importMutation], ); - const erroredProviderLabels = useMemo( - () => collectErroredProviderLabels(providersToFetch, queries, providerLabelById), + const providerErrorRows = useMemo( + () => collectProviderErrorRows(providersToFetch, queries, providerLabelById), [queries, providersToFetch, providerLabelById], ); - const isRefreshing = queries.some((query) => query.isFetching); + // Every query settles, errors included, so this stops turning (#2512). + const isRefreshing = queries.some((providerQuery) => providerQuery.isFetching); const handleRefresh = useCallback(() => { void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); }, [queryClient, sessionsQueryRoot]); + const handleRetryProvider = useCallback( + (provider: string) => { + void queryClient.refetchQueries({ + queryKey: buildSessionsQueryKey({ cwd: scopeCwd, query, limit: pageLimit, provider }), + }); + }, + [pageLimit, query, queryClient, scopeCwd], + ); + + const handleShowAll = useCallback(() => setIsShowingAllDirectories(true), []); + const handleLoadMore = useCallback(() => setPageLimit(nextPageLimit), []); + + const hosts = useHosts(); + const hostLabel = useMemo( + () => hosts.find((host) => host.serverId === serverId)?.label ?? serverId ?? "", + [hosts, serverId], + ); + const header = useMemo( () => ({ title: t("importSession.title"), + subtitle: ( + + ), + ...(supportsSearch + ? { + search: { + onChange: setSearchInput, + placeholder: t("importSession.searchPlaceholder"), + // The compact sheet keeps its content mounted while hidden, so the + // field has to be told to drop the text the state already dropped. + resetKey: visible ? "open" : "closed", + testID: "import-session-search", + }, + } + : {}), actions: , }), - [isRefreshing, handleRefresh, t], + [handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible], ); const isSnapshotUnsupported = requiresHostUpgrade; @@ -467,22 +705,27 @@ export function ImportSessionSheet({ const isQueryingProviders = queries.length > 0; const isLoadingSessions = isWaitingForSnapshot || - (isQueryingProviders && queries.some((query) => query.isLoading || query.isPending)); - const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError); + (isQueryingProviders && + queries.some((providerQuery) => providerQuery.isLoading || providerQuery.isPending)); + const allQueriesErrored = + isQueryingProviders && queries.every((providerQuery) => providerQuery.isError); const allQueriesSettled = - isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending); + isQueryingProviders && + queries.every((providerQuery) => !providerQuery.isLoading && !providerQuery.isPending); const { showEmptyState, emptyStateTitle } = computeEmptyState({ isLoadingSessions, allQueriesErrored, isQueryingProviders, allQueriesSettled, selectedProvider, + hasQuery: query.length > 0, aggregatedCount: aggregatedEntries.length, visibleCount: visibleEntries.length, totalAlreadyImportedCount, providerLabelById, }); const showFilter = filterProviders.length > 1; + const showLoadMore = hasMoreSessions(queries, pageLimit); return ( ) : ( - (() => { - const ProviderIcon = getProviderIcon(selectedProvider); - return ; - })() + )} {selectedProviderLabel} @@ -536,22 +776,31 @@ export function ImportSessionSheet({ hasNoImportableProviders={hasNoImportableProviders} isLoadingSessions={isLoadingSessions} hasRows={visibleEntries.length > 0} - allQueriesErrored={allQueriesErrored} - erroredProviderLabels={erroredProviderLabels} importErrored={importMutation.isError} /> + {providerErrorRows.length > 0 ? ( + + ) : null} {visibleEntries.length > 0 ? ( - - {visibleEntries.map((entry) => ( - - ))} + + ) : null} + {showLoadMore ? ( + + ) : null} {showEmptyState ? : null} @@ -560,6 +809,21 @@ export function ImportSessionSheet({ } const styles = StyleSheet.create((theme) => ({ + subtitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minWidth: 0, + }, + subtitleText: { + flexShrink: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + subtitleAction: { + marginHorizontal: -theme.spacing[2], + }, filterTriggerWrap: { paddingBottom: theme.spacing[2], }, @@ -568,6 +832,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1.5], alignSelf: "flex-start", + maxWidth: "100%", paddingVertical: theme.spacing[1.5], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, @@ -582,6 +847,8 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, }, filterTriggerText: { + flexShrink: 1, + minWidth: 0, color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, @@ -589,6 +856,10 @@ const styles = StyleSheet.create((theme) => ({ list: { gap: theme.spacing[1], }, + footer: { + paddingTop: theme.spacing[2], + alignItems: "center", + }, row: { flexDirection: "row", alignItems: "flex-start", @@ -636,7 +907,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, lineHeight: 20, }, - rowCwd: { + rowFolder: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, @@ -650,6 +921,26 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, + errorBanner: { + marginBottom: theme.spacing[2], + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[3], + }, + errorRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + errorText: { + flexShrink: 1, + minWidth: 0, + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + }, emptyState: { alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 23e3e2ca82..087a3ffca8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,15 +1,12 @@ -import { router, usePathname } from "expo-router"; +import { router } from "expo-router"; import { - CalendarClock, + CircleGauge, FolderPlus, GitBranch, - History, - Home, - Plus, - Search, Server, Settings, X, + type LucideIcon, } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; @@ -19,7 +16,6 @@ import { Text, useWindowDimensions, View, - type PressableStateCallbackType, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -33,50 +29,48 @@ import { SIDEBAR_RESIZE_FAIL_OFFSET, } from "@/components/sidebar-resize-handle-layout"; import { HostPicker } from "@/components/hosts/host-picker"; -import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu"; +import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; +import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useImportSession } from "@/hooks/use-import-session"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; -import { useHostFeature } from "@/runtime/host-features"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; +import { density } from "@/styles/density"; import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"; import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model"; import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; -import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { useWorkspace } from "@/stores/session-store-hooks"; +import { PluginSidebarItem } from "@/plugins/sidebar-items"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { useIsMobilePanelPresented } from "@/mobile-panels/provider"; -import { - buildOpenProjectRoute, - buildNewWorkspaceRoute, - buildSchedulesRoute, - buildSessionsRoute, - buildSettingsAddHostRoute, - buildSettingsRoute, -} from "@/utils/host-routes"; +import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; -import type { ShortcutKey } from "@/utils/format-shortcut"; +import { + UsageSidebarItem, + UsageSidebarRoot, + useHasUsageSummary, + useOpenSidebarUsage, +} from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -import { PluginSidebarItems } from "@/plugins"; type SidebarTheme = ReturnType["theme"]; @@ -100,39 +94,32 @@ interface SidebarSharedProps { toggleProjectCollapsed: (projectViewKey: string) => void; handleRefresh: () => void; handleOpenProject: () => void; - handleHome: () => void; + handleImportSession: () => void; handleSettings: () => void; labels: SidebarLabels; - newWorkspaceKeys: ShortcutKey[][] | null; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; } interface SidebarLabels { addProject: string; - newWorkspace: string; hosts: string; - home: string; settings: string; searchHosts: string; - sessions: string; - schedules: string; + usage: string; closeSidebar: string; } interface MobileSidebarProps extends SidebarSharedProps { + active: boolean; insetsTop: number; insetsBottom: number; closeSidebar: () => void; - handleViewMoreNavigate: () => void; - handleViewSchedulesNavigate: () => void; } interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; active: boolean; - handleViewMore: () => void; - handleViewSchedules: () => void; } export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) { @@ -174,6 +161,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole }, [isRevalidating, isManualRefresh]); const openProjectPicker = useOpenAddProject(); + const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const handleOpenProjectMobile = useCallback(() => { showMobileAgent(); @@ -214,34 +202,18 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole openHostOverview(serverId); }, []); - const handleHomeMobile = useCallback(() => { + const handleImportSessionMobile = useCallback(() => { showMobileAgent(); - router.push(buildOpenProjectRoute()); - }, [showMobileAgent]); + openImportSession(); + }, [openImportSession, showMobileAgent]); - const handleHomeDesktop = useCallback(() => { - router.push(buildOpenProjectRoute()); - }, []); - - const handleViewMoreNavigate = useCallback(() => { - router.push(buildSessionsRoute()); - }, []); - - const handleViewSchedulesNavigate = useCallback(() => { - router.push(buildSchedulesRoute()); - }, []); - - const newWorkspaceKeys = useShortcutKeys("new-workspace"); const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), - newWorkspace: t("sidebar.actions.newWorkspace"), hosts: t("sidebar.actions.hosts"), - home: t("sidebar.actions.home"), settings: t("sidebar.actions.settings"), searchHosts: t("sidebar.host.searchPlaceholder"), - sessions: t("sidebar.sections.sessions"), - schedules: t("sidebar.sections.schedules"), + usage: t(builtinSidebarNavLabelKey("usage")), closeSidebar: t("sidebar.actions.closeSidebar"), }), [t], @@ -265,44 +237,46 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole toggleProjectCollapsed, handleRefresh, labels, - newWorkspaceKeys, }; if (isCompactLayout) { return ( - - - + <> + + + + {importSessionSheet} + ); } return ( - - - + <> + + + + {importSessionSheet} + ); }); @@ -315,18 +289,17 @@ function FooterIconButton({ onPress, testID, label, - icon: Icon, - iconSize, + icon, + iconSizeAdjustment = 0, shortcutKeys, - theme, }: { onPress: () => void; testID: string; label: string; - icon: typeof FolderPlus; - iconSize?: number; + icon: LucideIcon; + /** Only for a glyph that reads larger than the others at the same size. */ + iconSizeAdjustment?: number; shortcutKeys?: ReturnType; - theme: SidebarTheme; buttonRef?: RefObject; }) { return ( @@ -344,72 +317,16 @@ function FooterIconButton({ onPress={onPress} > {({ hovered }) => ( - )}
- - - - - ); -} - -function footerAddProjectButtonStyle({ - hovered, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered]; -} - -function FooterAddProjectButton({ - onPress, - label, - shortcutKeys, - theme, -}: { - onPress: () => void; - label: string; - shortcutKeys: ReturnType; - theme: SidebarTheme; -}) { - return ( - - - - {({ hovered }) => { - const isHovered = Boolean(hovered); - return ( - <> - - - {label} - - - ); - }} - - - + @@ -417,12 +334,10 @@ function FooterAddProjectButton({ } function SidebarHostPicker({ - theme, label, onAddHost, onOpenHostSettings, }: { - theme: SidebarTheme; label: string; onAddHost: () => void; onOpenHostSettings: (serverId: string) => void; @@ -464,8 +379,8 @@ function SidebarHostPicker({ testID="sidebar-hosts-trigger" label={label} icon={Server} - iconSize={theme.iconSize.sm} - theme={theme} + // Server's two boxes fill more of the square than the other glyphs. + iconSizeAdjustment={-1} /> ); @@ -486,124 +401,112 @@ function IconTooltipContent({ ); } -const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({ - label, - testID, - variant, - shortcutKeys, - onBeforeNavigate, -}: { - label: string; - testID: string; - variant: "header" | "compact"; - shortcutKeys: ShortcutKey[][] | null; - onBeforeNavigate?: () => void; -}) { - const activeWorkspaceSelection = useActiveWorkspaceSelection(); - const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null; - const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null; - const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId); - const supportsWorkspaceMultiplicity = useHostFeature( - activeWorkspaceServerId, - "workspaceMultiplicity", - ); - const canUseActiveWorkspaceContext = Boolean( - activeWorkspace && - (supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)), - ); - - const handlePress = useCallback(() => { - onBeforeNavigate?.(); - router.push( - activeWorkspaceServerId - ? buildNewWorkspaceRoute( - activeWorkspace && canUseActiveWorkspaceContext - ? { - serverId: activeWorkspaceServerId, - sourceDirectory: activeWorkspace.projectRootPath, - projectId: activeWorkspace.projectId, - } - : { serverId: activeWorkspaceServerId }, - ) - : buildNewWorkspaceRoute(), - ); - }, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]); - - return ( - - ); -}); - function SidebarFooter({ - theme, handleOpenProject, - handleHome, handleSettings, labels, handleAddHost, handleOpenHostSettings, + onBeforeNavigate, }: { - theme: SidebarTheme; handleOpenProject: () => void; - handleHome: () => void; handleSettings: () => void; labels: { addProject: string; hosts: string; - home: string; settings: string; searchHosts: string; + usage: string; }; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; + onBeforeNavigate?: () => void; }) { const newAgentKeys = useShortcutKeys("new-agent"); const settingsKeys = useShortcutKeys("toggle-settings"); + // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. return ( - - - - - - - + + + + + + + + + + + - + + ); +} + +function SidebarUsageIcon({ label }: { label: string }) { + const openUsage = useOpenSidebarUsage(); + return ( + + ); +} + +/** + * The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. The + * Usage item is left out while it has no summary to show. + */ +function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { + const { items } = useSidebarNavItems("footer"); + const hasUsageSummary = useHasUsageSummary(); + const rowsRef = useRef(null); + const visibleItems = items.filter( + (item) => item.visible && (item.kind === "plugin" || hasUsageSummary), + ); + if (visibleItems.length === 0) return null; + return ( + <> + + {visibleItems.map((item) => + item.kind === "plugin" ? ( + + ) : ( + + ), + )} + + + ); } function MobileSidebar({ + active, theme, workspaceGroups, projectIconTargets, @@ -620,9 +523,8 @@ function MobileSidebar({ shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, - newWorkspaceKeys, handleOpenProject, - handleHome, + handleImportSession, handleSettings, labels, handleAddHost, @@ -630,25 +532,9 @@ function MobileSidebar({ insetsTop, insetsBottom, closeSidebar, - handleViewMoreNavigate, - handleViewSchedulesNavigate, }: MobileSidebarProps) { - const pathname = usePathname(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); - const isSessionsActive = pathname.includes("/sessions"); - const isSchedulesActive = pathname.includes("/schedules"); const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(); - const dragGestureHostPresented = useIsMobilePanelPresented("agent-list"); - - const handleViewMore = useCallback(() => { - closeSidebar(); - handleViewMoreNavigate(); - }, [closeSidebar, handleViewMoreNavigate]); - - const handleViewSchedules = useCallback(() => { - closeSidebar(); - handleViewSchedulesNavigate(); - }, [closeSidebar, handleViewSchedulesNavigate]); const handleWorkspacePress = useCallback(() => { closeSidebar(); @@ -671,33 +557,12 @@ function MobileSidebar({ > - - - - - - - + + )} @@ -773,23 +638,17 @@ function DesktopSidebar({ shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, - newWorkspaceKeys, handleOpenProject, - handleHome, + handleImportSession, handleSettings, labels, handleAddHost, handleOpenHostSettings, insetsTop, active, - handleViewMore, - handleViewSchedules, }: DesktopSidebarProps) { const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); - const pathname = usePathname(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); - const isSessionsActive = pathname.includes("/sessions"); - const isSchedulesActive = pathname.includes("/schedules"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); @@ -897,31 +756,7 @@ function DesktopSidebar({ ) : ( )} - - - - - - + {isInitialLoad && !hasActiveHostFilter ? ( @@ -942,6 +777,7 @@ function DesktopSidebar({ isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} onAddProject={handleOpenProject} + onImportSession={handleImportSession} listHeaderComponent={workspacesSectionHeaderElement} /> )} @@ -949,9 +785,7 @@ function DesktopSidebar({ state.setCommandCenterOpen); - const commandCenterKeys = useShortcutKeys("toggle-command-center"); - const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]); - const searchButtonStyle = useCallback( - ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.workspacesHeaderIconButton, - (hovered || pressed) && styles.workspacesHeaderIconButtonHovered, - ], - [], - ); - return ( Workspaces - - - - {({ hovered, pressed }) => ( - - )} - - - - - - @@ -1074,16 +873,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1], }, - workspacesHeaderIconButton: { - width: 28, - height: 28, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.md, - }, - workspacesHeaderIconButtonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, sidebarContent: { flex: 1, minHeight: 0, @@ -1095,7 +884,6 @@ const styles = StyleSheet.create((theme) => ({ right: 0, zIndex: 2, alignItems: "flex-end", - pointerEvents: "box-none", }, mobileCloseButton: { // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. @@ -1143,48 +931,30 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - sidebarFooter: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], + footerContainer: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, - footerIconRow: { + // Buttons sit edge to edge; their own inset spaces the glyphs. + sidebarFooter: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - footerAddProjectButton: { - minWidth: 0, - minHeight: 32, - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[1.5], paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[1.5], }, - footerAddProjectButtonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, + // Pushes Help and Settings to the end of the icon line. + footerSpacer: { + flex: 1, }, - footerAddProjectLabel: { - minWidth: 0, - flexShrink: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - footerAddProjectLabelHovered: { - color: theme.colors.foreground, + // Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows. + footerRows: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1.5], + gap: 2, }, footerIconButton: { - width: 28, - height: 28, + width: density.iconButton, + height: density.iconButton, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs index e4f61b8899..6ed821ceac 100644 --- a/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs +++ b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs @@ -54,6 +54,11 @@ const html = ` margin: 0; padding: 0; background: transparent; + height: 100%; + } + /* The host sizes this frame to the diagram, so the diagram fills the frame. */ + #diagram { + height: 100%; } #diagram svg { display: block; diff --git a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx new file mode 100644 index 0000000000..8b68ae61cb --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Modal, View } from "react-native"; +import { X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ZoomableViewport } from "@/components/zoomable-viewport"; +import { SPACING } from "@/styles/theme"; +import { WindowChromeRootRegion } from "@/utils/desktop-window"; +import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web"; +import type { DiagramColorScheme } from "./render-model"; +import { useMermaidRenderModel } from "./use-render-model"; + +interface MermaidFullscreenViewerProps { + source: string; + colorScheme: DiagramColorScheme; + onClose: () => void; +} + +// The diagram is already in the render cache, so the viewport knows its size before the +// fullscreen iframe has re-rendered it. +const FALLBACK_SIZE = { width: 240, height: 240 }; +const VIEWER_FIT = { padding: SPACING[6] }; + +export function MermaidFullscreenViewer({ + source, + colorScheme, + onClose, +}: MermaidFullscreenViewerProps) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { state, request, rendered, renderFailed } = useMermaidRenderModel({ + source, + phase: "complete", + colorScheme, + }); + const handleRendered = useCallback( + (message: MermaidRenderedMessage) => { + rendered({ + revision: message.revision, + source: message.source, + colorScheme: message.colorScheme, + dimensions: { height: message.height, width: message.width }, + }); + }, + [rendered], + ); + + useEffect(() => { + function handleKeydown(event: KeyboardEvent): void { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [onClose]); + + // The Modal focus trap walks descendants and focuses the first one that takes it. Focus the + // content layer ourselves (this effect runs after the trap's) so keystrokes stay in this + // document instead of disappearing into a focused diagram iframe. + const contentLayerRef = useRef(null); + useEffect(() => { + contentLayerRef.current?.focus(); + }, []); + + const visible = state.visible; + const contentSize = useMemo( + () => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE), + [visible], + ); + const actions = useMemo( + () => [ + { + icon: X, + label: t("common.actions.close"), + onPress: onClose, + testID: "mermaid-fullscreen-close", + }, + ], + [onClose, t], + ); + const contentLayerStyle = useMemo( + () => [ + styles.contentLayer, + { + paddingTop: insets.top, + paddingRight: insets.right, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + }, + ], + [insets.bottom, insets.left, insets.right, insets.top], + ); + + return ( + + + + + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */} +
+ + + +
+
+
+
+
+ ); +} + +const focusLayerDomStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + height: "100%", + minHeight: 0, + outline: "none", +}; + +const styles = StyleSheet.create((theme) => ({ + root: { flex: 1, minHeight: 0, minWidth: 0 }, + backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 }, + contentLayer: { position: "absolute", inset: 0 }, + viewport: { flex: 1 }, +})); diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx index c51ab75428..e79922ec43 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx @@ -1,23 +1,14 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentType, -} from "react"; -import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, View, type TextStyle, type ViewStyle } from "react-native"; +import { Code, Maximize2, Workflow } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { ZoomableViewport } from "@/components/zoomable-viewport"; import type { Theme } from "@/styles/theme"; import type { MarkdownFenceRendererProps } from "../types"; -import type { MermaidRenderRequest } from "./render-model"; -import { mermaidRuntimeHtml } from "./runtime/html.gen"; -import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages"; -import { MermaidRuntimeRequestDriver } from "./runtime/request-driver"; +import { MermaidFullscreenViewer } from "./fullscreen-viewer.web"; +import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web"; import { useMermaidRenderModel } from "./use-render-model"; import { getDiagramBoxStyle } from "./presentation"; @@ -25,100 +16,6 @@ interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps { colorScheme?: "light" | "dark"; } -interface MermaidIframeRuntimeProps { - request: MermaidRenderRequest | null; - height: number; - onRendered: (message: { - revision: number; - source: string; - colorScheme: "light" | "dark"; - height: number; - width: number; - }) => void; - onRenderFailed: (revision: number) => void; -} - -function MermaidIframeRuntime({ - request, - height, - onRendered, - onRenderFailed, -}: MermaidIframeRuntimeProps) { - const iframeRef = useRef(null); - const driverRef = useRef(null); - driverRef.current ??= new MermaidRuntimeRequestDriver(); - const iframeStyle = useMemo( - () => ({ - display: "block", - width: "100%", - height, - border: 0, - pointerEvents: "none", - background: "transparent", - }), - [height], - ); - - const sendRequest = useCallback((current: MermaidRenderRequest | null) => { - const target = iframeRef.current?.contentWindow; - if (!current || !target) { - return; - } - const message: MermaidRuntimeRenderMessage = { - type: "render", - revision: current.revision, - source: current.source, - colorScheme: current.colorScheme, - interactive: false, - }; - target.postMessage(message, "*"); - }, []); - - useEffect(() => { - sendRequest(driverRef.current?.update(request) ?? null); - }, [request, sendRequest]); - - useEffect(() => { - function receiveMessage(event: MessageEvent): void { - if (event.source !== iframeRef.current?.contentWindow) { - return; - } - const message = parseMermaidRuntimeMessage(event.data); - if (!message) { - return; - } - if (message.type === "bridgeReady") { - sendRequest(driverRef.current?.ready() ?? null); - return; - } - if (message.type === "renderError") { - onRenderFailed(message.revision); - sendRequest(driverRef.current?.settled(message.revision, false) ?? null); - return; - } - onRendered(message); - sendRequest(driverRef.current?.settled(message.revision, true) ?? null); - } - window.addEventListener("message", receiveMessage); - return () => window.removeEventListener("message", receiveMessage); - }, [onRenderFailed, onRendered, sendRequest]); - - return ( - `},\"putIntoIFrame\"),n$e=v((t,e,r,n,i)=>{let a=t.append(\"div\");a.attr(\"id\",r),n&&a.attr(\"style\",n);let s=a.append(\"svg\").attr(\"id\",e).attr(\"width\",\"100%\").attr(\"xmlns\",gNt);return i&&s.attr(\"xmlns:xlink\",i),s.append(\"g\"),t},\"appendDivSvgG\");function VK(t,e){return t.append(\"iframe\").attr(\"id\",e).attr(\"style\",\"width: 100%; height: 100%;\").attr(\"sandbox\",\"\")}v(VK,\"sandboxedIframe\");var INt=v((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},\"removeExistingElements\"),MNt=v(async function(t,e,r){lM();let n=qK(e);e=n.code;let i=It();ne.debug(i),e.length>(i?.maxTextSize??hNt)&&(e=dNt);let a=`#${t}`,s=\"i\"+t,o=\"#\"+s,l=\"d\"+t,u=\"#\"+l,h=v(()=>{let _=tt(f?o:u).node();_&&\"remove\"in _&&_.remove()},\"removeTempElements\"),d=tt(document.body),f=i.securityLevel===fNt,p=i.securityLevel===pNt,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=\"\"),f){let R=VK(tt(r),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(r);n$e(d,t,l,`font-family: ${g}`,mNt)}else{if(INt(document,t,l,s),f){let R=VK(tt(document.body),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(\"body\");n$e(d,t,l)}let m,y;try{m=await GK.fromText(e,{title:n.title})}catch(R){if(i.suppressErrorRendering)throw h(),R;m=await GK.fromText(\"error\"),y=R}let x=d.select(u).node(),b=m.type,T=x.firstChild,k=T.firstChild,C=m.renderer.getClasses?.(e,m),w=_Nt(i,b,C,a),S=document.createElement(\"style\");S.innerHTML=w,T.insertBefore(S,k);try{await m.renderer.draw(e,t,\"11.16.0\",m)}catch(R){throw i.suppressErrorRendering?h():$Mt.draw(e,t,\"11.16.0\"),R}let A=d.select(`${u} svg`),L=m.db.getAccTitle?.(),M=m.db.getAccDescription?.();K$e(b,A,L,M),d.select(`[id=\"${t}\"]`).selectAll(\"foreignobject > *\").attr(\"xmlns\",yNt);let P=d.select(u).node().innerHTML;if(ne.debug(\"config.arrowMarkerAbsolute\",i.arrowMarkerAbsolute),P=RNt(P,f,yl(i.arrowMarkerAbsolute)),f){let R=d.select(u+\" svg\").node();P=LNt(P,R)}else p||(P=to.sanitize(P,{ADD_TAGS:kNt,ADD_ATTR:SNt,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(sNt(),y)throw y;return h(),{diagramType:b,svg:P,bindFunctions:m.db.bindFunctions}},\"render\");function X$e(t={}){let e=Jn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),NJ(e),e?.theme&&e.theme in qc?e.themeVariables=qc[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=qc.default.getThemeVariables(e.themeVariables));let r=typeof e==\"object\"?DJ(e):AD();rC(r.logLevel),lM()}v(X$e,\"initialize\");var j$e=v((t,e={})=>{let{code:r}=WK(t);return GK.fromText(r,e)},\"getDiagramFromText\");function K$e(t,e,r,n){W$e(e,t),q$e(e,r,n,e.attr(\"id\"))}v(K$e,\"addA11yInfo\");var Im=Object.freeze({render:MNt,parse:Y$e,getDiagramFromText:j$e,initialize:X$e,getConfig:It,setConfig:_D,getSiteConfig:AD,updateSiteConfig:OJ,reset:v(()=>{CC()},\"reset\"),globalReset:v(()=>{CC(Gp)},\"globalReset\"),defaultConfig:Gp});rC(It().logLevel);CC(It());var DNt=v((t,e,r)=>{ne.warn(t),fA(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},\"handleError\"),Z$e=v(async function(t={querySelector:\".mermaid\"}){try{await NNt(t)}catch(e){if(fA(e)&&ne.error(e.str),wd.parseError&&wd.parseError(e),!t.suppressErrors)throw ne.error(\"Use the suppressErrors option to suppress these errors\"),e}},\"run\"),NNt=v(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:\".mermaid\"}){let n=Im.getConfig();ne.debug(`${t?\"\":\"No \"}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error(\"Nodes and querySelector are both undefined\");ne.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(ne.debug(\"Start On Load: \"+n?.startOnLoad),Im.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new Zt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,o=[];for(let l of Array.from(i)){if(ne.info(\"Rendering diagram: \"+l.id),l.getAttribute(\"data-processed\"))continue;l.setAttribute(\"data-processed\",\"true\");let u=`mermaid-${a.next()}`;s=l.innerHTML,s=CA(Zt.entityDecode(s)).trim().replace(//gi,\"
\");let h=Zt.detectInit(s);h&&ne.debug(\"Detected early reinit: \",h);try{let{svg:d,bindFunctions:f}=await tFe(u,s,l);l.innerHTML=d,t&&await t(u),f&&f(l)}catch(d){DNt(d,o,wd.parseError)}}if(o.length>0)throw o[0]},\"runThrowsErrors\"),Q$e=v(function(t){Im.initialize(t)},\"initialize\"),ONt=v(async function(t,e,r){ne.warn(\"mermaid.init is deprecated. Please use run instead.\"),t&&Q$e(t);let n={postRenderCallback:r,querySelector:\".mermaid\"};typeof e==\"string\"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await Z$e(n)},\"init\"),PNt=v(async(t,{lazyLoad:e=!0}={})=>{lM(),c5(...t),e===!1&&await iNt()},\"registerExternalDiagrams\"),J$e=v(function(){if(wd.startOnLoad){let{startOnLoad:t}=Im.getConfig();t&&wd.run().catch(e=>ne.error(\"Mermaid failed to initialize\",e))}},\"contentLoaded\");typeof document<\"u\"&&window.addEventListener(\"load\",J$e,!1);var BNt=v(function(t){wd.parseError=t},\"setParseErrorHandler\"),oM=[],zK=!1,eFe=v(async()=>{if(!zK){for(zK=!0;oM.length>0;){let t=oM.shift();if(t)try{await t()}catch(e){ne.error(\"Error executing queue\",e)}}zK=!1}},\"executeQueue\"),$Nt=v(async(t,e)=>new Promise((r,n)=>{let i=v(()=>new Promise((a,s)=>{Im.parse(t,e).then(o=>{a(o),r(o)},o=>{ne.error(\"Error parsing\",o),wd.parseError?.(o),s(o),n(o)})}),\"performCall\");oM.push(i),eFe().catch(n)}),\"parse\"),tFe=v((t,e,r)=>new Promise((n,i)=>{let a=v(()=>new Promise((s,o)=>{Im.render(t,e,r).then(l=>{s(l),n(l)},l=>{ne.error(\"Error parsing\",l),wd.parseError?.(l),o(l),i(l)})}),\"performCall\");oM.push(a),eFe().catch(i)}),\"render\"),FNt=v(()=>Object.keys(Bd).map(t=>({id:t})),\"getRegisteredDiagramsMetadata\"),wd={startOnLoad:!0,mermaidAPI:Im,parse:$Nt,render:tFe,init:ONt,run:Z$e,registerExternalDiagrams:PNt,registerLayoutLoaders:_G,initialize:Q$e,parseError:void 0,contentLoaded:J$e,setParseErrorHandler:BNt,detectType:wC,registerIconPacks:qy,getRegisteredDiagramsMetadata:FNt},UK=wd;function zNt(t){return typeof t==\"object\"&&t!==null}function GNt(t){return t===\"light\"||t===\"dark\"}function rFe(t){return!zNt(t)||t.type!==\"render\"||typeof t.revision!=\"number\"||!Number.isInteger(t.revision)||typeof t.source!=\"string\"||!GNt(t.colorScheme)||typeof t.interactive!=\"boolean\"?null:{type:\"render\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,interactive:t.interactive}}function YK(t){window.ReactNativeWebView?.postMessage?.(JSON.stringify(t)),window.parent!==window&&window.parent.postMessage(t,\"*\")}function VNt(t){UK.initialize({startOnLoad:!1,securityLevel:\"strict\",suppressErrorRendering:!0,theme:t===\"dark\"?\"dark\":\"default\",secure:[\"secure\",\"securityLevel\",\"startOnLoad\",\"maxTextSize\",\"suppressErrorRendering\",\"maxEdges\",\"theme\",\"themeVariables\",\"themeCSS\"],flowchart:{htmlLabels:!1},class:{htmlLabels:!1}})}function WNt(t){document.querySelector('meta[name=\"viewport\"]')?.setAttribute(\"content\",t?\"width=device-width, initial-scale=1, maximum-scale=8\":\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\")}var XK=0,cM=null,uM=!1,HK=!1;async function qNt(t){try{VNt(t.colorScheme);let{svg:e}=await UK.render(`paseo-mermaid-${t.revision}`,t.source);if(t.revision!==XK)return;WNt(t.interactive);let r=document.getElementById(\"diagram\");if(!r)return;r.innerHTML=e;let n=r.querySelector(\"svg\")?.getBoundingClientRect();YK({type:\"rendered\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,height:Math.ceil(n?.height??r.scrollHeight),width:Math.ceil(n?.width??r.scrollWidth)})}catch{t.revision===XK&&YK({type:\"renderError\",revision:t.revision})}}async function UNt(){if(!uM){uM=!0;try{for(;cM;){let t=cM;cM=null,await qNt(t)}}finally{uM=!1}}}function nFe(t){let e=rFe(t);e&&(XK=e.revision,cM=e,!(uM||HK)&&(HK=!0,window.setTimeout(()=>{HK=!1,UNt()},0)))}window.__PASEO_MERMAID_RUNTIME_RECEIVE__=nFe;window.addEventListener(\"message\",t=>{t.source===window.parent&&nFe(t.data)});YK({type:\"bridgeReady\"});})();\n/*! Bundled license information:\n\ndompurify/dist/purify.es.mjs:\n (*! @license DOMPurify 3.4.12 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.12/LICENSE *)\n\nmermaid/dist/chunks/mermaid.core/chunk-ZIRB5QZD.mjs:\n (*! Bundled license information:\n \n js-yaml/dist/js-yaml.mjs:\n (*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT *)\n *)\n\nlodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" --repo lodash/lodash#4.18.1 -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n\ncytoscape/dist/cytoscape.esm.mjs:\n (*!\n Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable\n Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)\n Licensed under The MIT License (http://opensource.org/licenses/MIT)\n *)\n (*!\n Event object based on jQuery events, MIT license\n \n https://jquery.org/license/\n https://tldrlegal.com/license/mit-license\n https://github.com/jquery/jquery/blob/master/src/event.js\n *)\n (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n\n@mermaid-js/parser/dist/chunks/mermaid-parser.core/chunk-KEIR6QF5.mjs:\n (*! Bundled license information:\n \n lodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n *)\n\nmermaid/dist/mermaid.core.mjs:\n (*! Check if previously processed *)\n (*!\n * Wait for document loaded before starting the execution\n *)\n*/\n\n \n"; +export const mermaidRuntimeHtml = "\n\n \n \n \n \n \n \n \n
\n \n \n"; diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts index 1dda832eb3..7ffbbc79db 100644 --- a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts @@ -29,9 +29,13 @@ function waitForRuntimeMessage( }); } -async function mountRuntime(): Promise { +async function mountRuntime(size?: { width: number; height: number }): Promise { const frame = document.createElement("iframe"); frame.sandbox.add("allow-scripts"); + if (size) { + frame.style.width = `${size.width}px`; + frame.style.height = `${size.height}px`; + } const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady"); frame.srcdoc = mermaidRuntimeHtml; document.body.append(frame); @@ -40,6 +44,13 @@ async function mountRuntime(): Promise { return frame; } +function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } { + if (message.type !== "rendered") { + throw new Error(`Expected a rendered diagram, got ${message.type}`); + } + return { height: message.height, width: message.width }; +} + function render( frame: HTMLIFrameElement, input: { revision: number; source: string }, @@ -85,6 +96,22 @@ describe("Mermaid sandbox runtime", () => { expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource }); }); + /** + * The host sizes this frame from the reported size, so a size that depends on the frame feeds + * back: every re-render measures inside a frame the previous measurement already shrank by the + * container's padding, and a streaming diagram ratchets down to a few pixels. + */ + it("reports the same size whatever frame the host gives it", async () => { + const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]"; + const narrowFrame = await mountRuntime({ height: 60, width: 60 }); + const wideFrame = await mountRuntime({ height: 600, width: 900 }); + + const narrow = await render(narrowFrame, { revision: 1, source }); + const wide = await render(wideFrame, { revision: 1, source }); + + expect(renderedSize(narrow)).toEqual(renderedSize(wide)); + }); + it("coalesces queued input and never reports an obsolete result", async () => { const frame = await mountRuntime(); const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`; diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts index 37e3aeddb5..d8b45ae415 100644 --- a/packages/app/src/components/markdown/html-ish.test.ts +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -113,6 +113,85 @@ describe("splitHtmlishMarkdown", () => { ]); }); + it("renders a linked picture inside a heading without exposing its HTML", () => { + const source = [ + '

', + '', + '', + 'Retrigger', + "

", + "", + "Confidence Score: 5/5", + "", + "
SummaryNo outstanding findings.
", + ].join("\n"); + + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + alt: "Retrigger", + src: "https://example.com/default.svg", + href: "https://example.com/retrigger?id=73851569", + width: 85, + height: 24, + }, + { kind: "markdown", text: "\n\nConfidence Score: 5/5\n\n" }, + { kind: "details", summary: "Summary", body: "No outstanding findings." }, + ]); + }); + + it.each([ + ["", ""], + ["

", "

"], + ['', ""], + ])("uses the picture fallback without activating unsafe links (%s)", (wrapper, closing) => { + const picture = + 'Image'; + + expect(splitHtmlishMarkdown(`${wrapper}${picture}${closing}`)).toEqual([ + { kind: "inlineImage", src: "https://example.com/default.svg", alt: "Image" }, + ]); + }); + + it("keeps picture examples literal inside inline and fenced code", () => { + const picture = + ''; + const source = `Use \`${picture}\`\n\n\`\`\`html\n${picture}\n\`\`\``; + expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]); + }); + + it.each(["p", "div"])("renders a linked picture inside %s", (wrapper) => { + const source = `<${wrapper}>Image`; + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + src: "https://example.com/default.svg", + alt: "Image", + href: "https://example.com/page", + }, + ]); + }); + + it.each(["h2", "p", "div"])("keeps text after an image-only %s on a separate row", (wrapper) => { + const source = `<${wrapper}>ImageNext section`; + expect(splitHtmlishMarkdown(source)).toEqual([ + { kind: "inlineImage", src: "https://example.com/image.svg", alt: "Image" }, + { kind: "markdown", text: "Next section" }, + ]); + }); + + it("handles deeply nested image wrappers without exhausting the stack", () => { + const source = `${"
".repeat(10000)}Image${"
".repeat(10000)}`; + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + src: "https://example.com/image.svg", + alt: "Image", + href: "https://example.com/page", + }, + ]); + }); + it("preserves inline image parts inside details bodies", () => { expect( splitHtmlishMarkdown( @@ -312,6 +391,43 @@ describe("splitHtmlishMarkdown", () => { ); }); + it("keeps table rows after a comparison that only looks like a tag", () => { + const source = [ + "| ID | Note | Status |", + "|---|---|---|", + "| a-1 | accepts 1 y.", + ].join("\n"); + + expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]); + }); + + it.each(["See for more.", "Stop when a and a { + expect(normalizeHtmlishMarkdown(source)).toBe(source); + }, + ); + + it.each([ + "Call As() before limits::max()", + "Ctrl then ", + ])("echoes unknown tags in %j as written", (source) => { + expect(normalizeHtmlishMarkdown(source)).toBe(source); + }); + + it("rebuilds an unknown tag that a code span splits", () => { + expect(normalizeHtmlishMarkdown('hi')).toBe("`x`hi"); + }); + + it("still reads tags and comments next to literal angle brackets", () => { + expect(normalizeHtmlishMarkdown("1bold 3<4")).toBe( + "1 { const source = "
OpenStill open"; diff --git a/packages/app/src/components/markdown/html-ish.ts b/packages/app/src/components/markdown/html-ish.ts index 93a88cd4b8..bd095c60b3 100644 --- a/packages/app/src/components/markdown/html-ish.ts +++ b/packages/app/src/components/markdown/html-ish.ts @@ -29,7 +29,13 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm; const BACKTICK_RUN_RE = /`+/g; const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i; const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i; -const VOID_HTML_TAGS = new Set(["br", "img"]); +// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately. +const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`; +const HTML_TAG_RE = new RegExp( + String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`, + "y", +); +const VOID_HTML_TAGS = new Set(["br", "img", "source"]); const MARKDOWN_TAG_WRAPPERS: Readonly> = { b: ["**", "**"], del: ["~~", "~~"], @@ -140,25 +146,73 @@ function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImagePars return image ? { part: image, end: start + 1 } : null; } - if (token.name !== "a") { + if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) { return null; } - const closeIndex = findMatchingClose(tokens, start, "a"); - if (closeIndex === null) { - return null; - } + return parseWrappedImageAt(tokens, start); +} - const image = getSingleImageChild(tokens.slice(start + 1, closeIndex)); - if (!image) { - return null; - } +function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null { + // Walk an image-only wrapper chain once. Recursing over copied child slices + // makes deeply nested review HTML quadratic and can exhaust the JS stack. + const wrappers: string[] = []; + let image: MarkdownInlineImagePart | null = null; + let href: string | undefined; + let hasLink = false; - const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href)); - return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null; + for (let index = start; index < tokens.length; index += 1) { + const child = tokens[index]; + if (child.kind === "comment" || isWhitespaceText(child)) { + continue; + } + if (child.kind !== "tag") { + return null; + } + if (child.closing) { + if (!image || wrappers.pop() !== child.name) { + return null; + } + if (wrappers.length === 0) { + return { part: image, end: index + 1 }; + } + continue; + } + // Pictures use their img fallback, not responsive source selection. + if (child.name === "source" && wrappers.at(-1) === "picture") { + continue; + } + if (image) { + return null; + } + if (child.name === "img") { + image = imageTokenToInlineImage(child, href); + if (!image) { + return null; + } + continue; + } + if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) { + return null; + } + if (child.name === "a" && !hasLink) { + href = safeHref(child.attributes.href); + hasLink = true; + } + wrappers.push(child.name); + } + return null; +} + +function isImageBlockWrapper(token: HtmlToken | undefined): boolean { + return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div"); } function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean { + if (isImageBlockWrapper(tokens[start])) { + return false; + } + const previous = tokens[start - 1]; // At line start if nothing precedes this image, or the preceding token ends with only // whitespace since the last newline (covers a bare space between two images on the same line). @@ -191,6 +245,9 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number) } // An inline image tag — skip over it (the image itself and its possible wrapping close tag). if (token.kind === "tag") { + if (isImageBlockWrapper(token)) { + return false; + } const imageResult = parseInlineImageAt(tokens, cursor); if (imageResult) { cursor = imageResult.end; @@ -322,14 +379,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string { } const children = tokens.slice(index + 1, closeIndex); - output += renderHtmlTag(token, children); + const close = tokens[closeIndex]; + const closeRaw = isClosingTag(close, token.name) ? close.raw : ``; + output += renderHtmlTag(token, children, closeRaw); index = closeIndex; } return output; } -function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string { +function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string { if (token.name === "a") { return renderLinkToken(token, children); } @@ -349,7 +408,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string { if (token.name === "p" || token.name === "div") { return `\n\n${renderInlineTokens(children).trim()}\n\n`; } - return `${token.raw}${renderInlineTokens(children)}`; + return `${token.raw}${renderInlineTokens(children)}${closeRaw}`; } function renderTableTokens(tokens: HtmlToken[]): string { @@ -555,7 +614,15 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] { function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser { let stripNextLeadingLineBreak = false; let skippedLength = 0; + let writtenLength = 0; + let chunkStart = 0; let parser!: Parser; + // Unknown tags render as text, so keep them as written: the parser lowercases names + // and rewrites attributes. A tag split by a protected range has no single slice. + const readRawTag = (): string | null => + parser.startIndex < chunkStart + ? null + : source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength); parser = new Parser( { onopentag(name, attributes, isImplied) { @@ -569,7 +636,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT closing: false, selfClosing, attributes, - raw: renderStartTag(name, attributes), + raw: readRawTag() ?? renderStartTag(name, attributes), }); }, onclosetag(name, isImplied) { @@ -582,7 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT closing: true, selfClosing: false, attributes: {}, - raw: ``, + raw: readRawTag() ?? ``, }); }, ontext(value) { @@ -609,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT ); return { write(chunk) { + chunkStart = writtenLength; + writtenLength += chunk.length; parser.write(chunk); }, skip(length) { @@ -647,7 +716,58 @@ function escapeAttribute(value: string): string { function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] { const fencedRanges = getFencedCodeRanges(source); - return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]); + const codeRanges = mergeProtectedRanges([ + ...fencedRanges, + ...getInlineCodeRanges(source, fencedRanges), + ]); + return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]); +} + +/** + * htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up + * to the next `>`, so `1", index + 2); + commentClose = close === -1 ? source.length : close; + } + htmlEnd = commentClose === source.length ? null : commentClose + 3; + } else { + HTML_TAG_RE.lastIndex = index; + htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null; + } + + if (htmlEnd === null) { + ranges.push({ start: index, end: index + 1 }); + htmlEnd = index + 1; + } + index = source.indexOf("<", htmlEnd); + } + + return ranges; } function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] { diff --git a/packages/app/src/components/markdown/inline-image-size.ts b/packages/app/src/components/markdown/inline-image-size.ts index c4f4b67ff3..8f83f80fcc 100644 --- a/packages/app/src/components/markdown/inline-image-size.ts +++ b/packages/app/src/components/markdown/inline-image-size.ts @@ -8,7 +8,6 @@ export interface InlineImageExplicitDimensions { height?: number; } -const INLINE_IMAGE_FALLBACK_SIZE = 16; const INLINE_IMAGE_MAX_WIDTH = 240; const INLINE_IMAGE_MAX_HEIGHT = 160; @@ -59,5 +58,5 @@ function resolveInlineImageDimensions(input: { return { width: input.explicit.height, height: input.explicit.height }; } - return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE }; + return input.natural ?? { width: INLINE_IMAGE_MAX_WIDTH, height: INLINE_IMAGE_MAX_HEIGHT }; } diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 1dc279a2d5..2dcab5c327 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -63,10 +63,10 @@ describe("resolveInlineImageSize", () => { }); }); - it("uses a generic small fallback when no dimensions are known", () => { + it("reserves a capped 3:2 placeholder when no dimensions are known", () => { expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({ - width: 16, - height: 16, + width: 240, + height: 160, }); }); }); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 61505326bc..83380ea919 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -28,6 +28,7 @@ import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-t import { MarkdownTableCellText } from "@/components/markdown-text-selection"; import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list"; import { markdownNodeContainsType } from "@/utils/markdown-ast"; +import { createMarkdownParser } from "@/utils/markdown-parser"; import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles"; import type { Theme } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -38,6 +39,10 @@ import { type MarkdownInlineImagePart, } from "./html-ish"; import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size"; +import { + getAssistantImageMetadata, + setAssistantImageMetadata, +} from "@/utils/assistant-image-metadata"; import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups"; import { colorMarkdownLinkChildren } from "./link-children"; import { MarkdownLinkText } from "./link-text"; @@ -65,7 +70,9 @@ function compactMarkdownStyleMapping(theme: Theme): Partial(); - return groups.map((group) => { - const identity = - group.kind === "part" - ? getMarkdownPartIdentity(group.part) - : `imageText:${group.images.map((i) => i.src).join(",")}:${group.lead.slice(0, 80)}`; - const seenCount = seen.get(identity) ?? 0; - seen.set(identity, seenCount + 1); - return { key: `${identity}:${seenCount}`, group }; - }); -} - -function getMarkdownPartIdentity(part: MarkdownDisplayPart): string { - if (part.kind === "markdown") { - return `markdown:${part.text.slice(0, 80)}`; - } - if (part.kind === "inlineImage") { - return `inlineImage:${part.src}:${part.alt}`; - } - return `details:${part.summary.slice(0, 80)}:${part.body.slice(0, 80)}`; + return groups.map((group, index) => ({ key: `${group.kind}:${index}`, group })); } function MarkdownPart({ @@ -217,11 +205,16 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): { failed: boolean; setFailed: (failed: boolean) => void; } { - const [natural, setNatural] = useState(null); + const cached = useMemo(() => getAssistantImageMetadata({ source: part.src }), [part.src]); + const [resolved, setResolved] = useState<{ + source: string; + dimensions: InlineImageDimensions; + } | null>(null); + const natural = resolved?.source === part.src ? resolved.dimensions : cached; const [failed, setFailed] = useState(false); useEffect(() => { - if (part.width && part.height) { + if ((part.width && part.height) || cached) { return; } @@ -229,8 +222,9 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): { Image.getSize( part.src, (width, height) => { + setAssistantImageMetadata({ source: part.src }, { width, height }); if (!cancelled) { - setNatural({ width, height }); + setResolved({ source: part.src, dimensions: { width, height } }); } }, () => { @@ -243,7 +237,7 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): { return () => { cancelled = true; }; - }, [part.height, part.src, part.width]); + }, [cached, part.height, part.src, part.width]); return { natural, failed, setFailed }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index e8a0fdbb69..4587556ebe 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,5 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { TaskListRow } from "@/components/task-list-row"; import { @@ -30,19 +32,14 @@ import type MarkdownIt from "markdown-it"; import { type ASTNode, type RenderRules } from "react-native-markdown-display"; import MaskedView from "@react-native-masked-view/masked-view"; import { - Circle, Info, - CheckCircle, XCircle, - FileText, ChevronRight, - ChevronDown, Check, CheckSquare, CircleDot, Copy, Plus, - RotateCcw, TriangleAlertIcon, Scissors, MicVocal, @@ -60,7 +57,6 @@ import Animated, { withTiming, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; -import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer"; import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream"; @@ -75,12 +71,15 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { MarkdownFenceBlock } from "@/components/markdown/fence"; import type { MarkdownPhase } from "@/components/markdown/fence/types"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { colorMarkdownLinkChildren } from "@/components/markdown/link-children"; import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; +import { getTurnDurationLabel } from "./assistant-turn-footer-label"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate"; +import { isRenderProfileEnabled } from "@/utils/render-profiler"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; @@ -101,7 +100,7 @@ import { AttachmentLabel, AttachmentThumbnail, } from "@/components/attachment-pill"; -import { AttachmentLightbox } from "@/components/attachment-lightbox"; +import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { isWeb, isNative } from "@/constants/platform"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; @@ -111,10 +110,12 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist import { useRetainedPanelActive } from "@/components/retained-panel"; import { markdownCopyDataSet, + markdownCopyImageDataSet, markdownCopyOrderedListDataSet, markdownCopyTableCellDataSet, type MarkdownCopyInlineTag, } from "@/assistant-selection-copy/markup"; +import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit"; export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; @@ -171,6 +172,9 @@ const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedNotificationInfo = withUnistyles(Info); +const ThemedNotificationWarning = withUnistyles(TriangleAlertIcon); +const ThemedNotificationError = withUnistyles(XCircle); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -180,6 +184,8 @@ const mutedForegroundColorMapping = (theme: Theme) => ({ color: theme.colors.mutedForeground, }); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); +const infoColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[300] }); +const warningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { 0% { @@ -419,6 +425,8 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage ); } +const MESSAGE_TEXT_DATASET = { messageText: "true" }; + export const UserMessage = memo(function UserMessage({ serverId, agentId, @@ -439,6 +447,10 @@ export const UserMessage = memo(function UserMessage({ const [isHovered, setIsHovered] = useState(false); const [lightboxMetadata, setLightboxMetadata] = useState(null); const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []); + const lightboxSource = useMemo( + () => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null), + [lightboxMetadata], + ); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; const hasImages = images.length > 0; @@ -534,7 +546,7 @@ export const UserMessage = memo(function UserMessage({ ) : null} {hasText ? ( - + {message} ) : null} @@ -564,7 +576,7 @@ export const UserMessage = memo(function UserMessage({ ) : null} - + ); }); @@ -572,7 +584,7 @@ export const UserMessage = memo(function UserMessage({ interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; - durationMs?: number; + durationMs?: number | null; onFork?: (target: AssistantForkTarget) => Promise | void; } @@ -610,9 +622,8 @@ const TIMESTAMP_REVEAL_MS = 3000; /** * Footer rendered next to the copy button at the end of an assistant turn. - * Always shows the turn duration; swaps to the end timestamp on hover (web) - * or tap (native). The hidden sizer keeps the label width stable while the - * visible text swaps. + * Shows the turn duration and swaps to the end timestamp when both are known. + * A turn without a visible start shows its end timestamp directly. */ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, @@ -620,6 +631,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ durationMs, onFork, }: AssistantTurnFooterProps) { + const { t } = useTranslation(); const [hovered, setHovered] = useState(false); const [pressedReveal, setPressedReveal] = useState(false); const revealTimerRef = useRef | null>(null); @@ -634,15 +646,17 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ }, []); const durationLabel = useMemo( - () => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""), - [durationMs], + () => + durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "", + [durationMs, t], ); const timestampLabel = useMemo( () => (completedAt ? formatMessageTimestamp(completedAt) : ""), [completedAt], ); - const canSwap = Boolean(timestampLabel); + const primaryLabel = durationLabel || timestampLabel; + const canSwap = Boolean(durationLabel && timestampLabel); const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal); const handleHoverIn = useCallback(() => setHovered(true), []); @@ -673,22 +687,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ containerStyle={assistantTurnFooterStylesheet.copyButton} /> {canFork ? : null} - {durationLabel ? ( + {primaryLabel ? ( {/* Sizer reserves space for whichever label is longer so the container width is stable across hover transitions. */} - {durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel} + {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel} - {showTimestamp ? timestampLabel : durationLabel} + {showTimestamp ? timestampLabel : primaryLabel} @@ -737,6 +751,7 @@ export const LiveElapsed = memo(function LiveElapsed({ }); interface AssistantMessageProps { + renderFullContent?: boolean; occurrenceKey: string; message: string; timestamp: number; @@ -758,6 +773,13 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ containerCompactBottom: { paddingBottom: 0, }, + cappedNotice: { + marginTop: theme.spacing[3], + fontFamily: theme.fontFamily.ui, + fontSize: theme.fontSize.base, + fontStyle: "italic", + color: theme.colors.foregroundMuted, + }, imageFrame: { width: "100%", minHeight: 160, @@ -795,8 +817,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ }, })); -const ASSISTANT_IMAGE_MIN_HEIGHT = 160; - function AssistantMarkdownImage({ source, occurrenceKey, @@ -814,6 +834,10 @@ function AssistantMarkdownImage({ workspaceRoot?: string; serverId?: string; }) { + const { t } = useTranslation(); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); const containerStyle = useMemo>( () => ({ marginTop: hasLeadingContent ? 16 : 0, @@ -837,29 +861,36 @@ function AssistantMarkdownImage({ [containerStyle], ); const imageSizeStyle = useMemo(() => { - if (aspectRatio) { - return { aspectRatio }; - } - return { height: ASSISTANT_IMAGE_MIN_HEIGHT }; - }, [aspectRatio]); + if (image.status === "failed") return { height: 160 }; + return { aspectRatio: aspectRatio ?? ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO }; + }, [aspectRatio, image.status]); const surfaceStyle = useMemo>( () => [assistantMessageStylesheet.imageSurface, imageSizeStyle], [imageSizeStyle], ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); const stateFrameStyle = useMemo>( () => [ assistantMessageStylesheet.imageFrame, containerStyle, - { height: ASSISTANT_IMAGE_MIN_HEIGHT }, + imageSizeStyle, assistantMessageStylesheet.imageState, ], - [containerStyle], + [containerStyle, imageSizeStyle], ); + const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]); if (image.status === "failed") { return ( - + {image.message} ); @@ -867,29 +898,42 @@ function AssistantMarkdownImage({ if (!binding) { return ( - + ); } return ( - - - + + - {image.status === "loading" ? ( - - - - ) : null} - + accessibilityRole="image" + accessibilityLabel={alt} + > + + {image.status === "loading" ? ( + + + + ) : null} + + + ); } @@ -1449,6 +1493,7 @@ function MarkdownListView({ } export const AssistantMessage = memo(function AssistantMessage({ + renderFullContent = false, occurrenceKey, message, timestamp: _timestamp, @@ -1458,11 +1503,27 @@ export const AssistantMessage = memo(function AssistantMessage({ spacing = "default", phase, }: AssistantMessageProps) { + const { t } = useTranslation(); const markdownParser = useMemo(createAssistantMarkdownParser, []); + const streamingMarkdownParser = useMemo( + () => createAssistantMarkdownParser({ streaming: true }), + [], + ); + const renderedMessage = useMemo( + () => + renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message), + [message, renderFullContent], + ); + const revealedText = useRevealedText(renderedMessage.text, phase); + const revealedMessage = renderFullContent ? renderedMessage.text : revealedText; + const fullMessageByteLength = useMemo( + () => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null), + [message, phase, renderedMessage.capped], + ); const fileLinkActions = useAssistantFileLinkActions(); const handleMarkdownLinkPress = useStableEvent((url: string) => { - fileLinkActions.open({ href: url }, "side"); + fileLinkActions.open({ href: url }, "preferred"); // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; @@ -1897,7 +1958,7 @@ export const AssistantMessage = memo(function AssistantMessage({ }; }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); - const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); + const blocks = useMemo(() => splitMarkdownBlocks(revealedMessage), [revealedMessage]); const keyedBlocks = useMemo( () => blocks.map((block, index) => ({ key: `block:${index}`, block })), [blocks], @@ -1913,9 +1974,20 @@ export const AssistantMessage = memo(function AssistantMessage({ ], [spacing], ); + const revealDataSet = useMemo( + () => + isRenderProfileEnabled() + ? { + ...MESSAGE_TEXT_DATASET, + revealKey: occurrenceKey, + revealLength: String(revealedMessage.length), + } + : MESSAGE_TEXT_DATASET, + [occurrenceKey, revealedMessage.length], + ); return ( - + {keyedBlocks.map(({ key, block }, index) => ( ))} + {fullMessageByteLength !== null ? ( + + {t("agentStream.messageCapped", { bytes: fullMessageByteLength })} + + ) : null} ); }); @@ -1993,41 +2077,28 @@ export const SpeakMessage = memo(function SpeakMessage({ ); }); -interface ActivityLogProps { - type: "system" | "info" | "success" | "error" | "artifact"; +interface NotificationProps { + level: "info" | "warning" | "error"; message: string; - timestamp: number; - metadata?: Record; - artifactId?: string; - artifactType?: string; - title?: string; - onArtifactClick?: (artifactId: string) => void; disableOuterSpacing?: boolean; } -const activityLogStylesheet = StyleSheet.create((theme) => ({ - pressable: { +const notificationStylesheet = StyleSheet.create((theme) => ({ + container: { borderRadius: theme.borderRadius.md, overflow: "hidden", }, - pressableSpacing: { + containerSpacing: { marginBottom: theme.spacing[1], }, - pressableActive: { - opacity: 0.7, - }, - systemBg: { - backgroundColor: "rgba(39, 39, 42, 0.5)", - }, infoBg: { - backgroundColor: "rgba(30, 58, 138, 0.3)", + backgroundColor: "rgba(147, 197, 253, 0.1)", }, - successBg: { - backgroundColor: "rgba(20, 83, 45, 0.3)", + warningBg: { + backgroundColor: "rgba(245, 158, 11, 0.1)", }, - errorBg: {}, - artifactBg: { - backgroundColor: "rgba(30, 58, 138, 0.4)", + errorBg: { + backgroundColor: "rgba(239, 68, 68, 0.1)", }, content: { paddingHorizontal: theme.spacing[3], @@ -2047,137 +2118,63 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({ flex: 1, }, messageText: { + color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 20, }, - detailsRow: { - flexDirection: "row", - alignItems: "center", - marginTop: theme.spacing[1], - }, - detailsText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginRight: theme.spacing[1], - }, - metadataContainer: { - marginTop: theme.spacing[2], - backgroundColor: "rgba(0, 0, 0, 0.5)", - borderRadius: theme.borderRadius.base, - padding: theme.spacing[2], - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - metadataText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.code, - fontFamily: theme.fontFamily.mono, - lineHeight: 16, - }, })); -export const ActivityLog = memo(function ActivityLog({ - type, +export const Notification = memo(function Notification({ + level, message, - timestamp: _timestamp, - metadata, - artifactId, - artifactType, - title, - onArtifactClick, disableOuterSpacing, -}: ActivityLogProps) { - const { t } = useTranslation(); +}: NotificationProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); - const [isExpanded, setIsExpanded] = useState(false); const typeConfig = { - system: { - bg: activityLogStylesheet.systemBg, - color: "#a1a1aa", - Icon: Circle, + info: { + bg: notificationStylesheet.infoBg, + iconColorMapping: infoColorMapping, + Icon: ThemedNotificationInfo, }, - info: { bg: activityLogStylesheet.infoBg, color: "#60a5fa", Icon: Info }, - success: { - bg: activityLogStylesheet.successBg, - color: "#4ade80", - Icon: CheckCircle, + warning: { + bg: notificationStylesheet.warningBg, + iconColorMapping: warningColorMapping, + Icon: ThemedNotificationWarning, }, error: { - bg: activityLogStylesheet.errorBg, - color: "#f87171", - Icon: XCircle, - }, - artifact: { - bg: activityLogStylesheet.artifactBg, - color: "#93c5fd", - Icon: FileText, + bg: notificationStylesheet.errorBg, + iconColorMapping: destructiveColorMapping, + Icon: ThemedNotificationError, }, }; - const config = typeConfig[type]; + const config = typeConfig[level]; const IconComponent = config.Icon; - const handlePress = useCallback(() => { - if (type === "artifact" && artifactId && onArtifactClick) { - onArtifactClick(artifactId); - } else if (metadata) { - setIsExpanded((prev) => !prev); - } - }, [type, artifactId, onArtifactClick, metadata]); - - const displayMessage = - type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message; - - const isInteractive = type === "artifact" || metadata; - const pressableStyle = useMemo( + const containerStyle = useMemo( () => [ - activityLogStylesheet.pressable, - !resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing, + notificationStylesheet.container, + !resolvedDisableOuterSpacing && notificationStylesheet.containerSpacing, config.bg, - isInteractive && activityLogStylesheet.pressableActive, ], - [resolvedDisableOuterSpacing, config.bg, isInteractive], + [resolvedDisableOuterSpacing, config.bg], ); - const messageTextStyle = useMemo( - () => [activityLogStylesheet.messageText, { color: config.color }], - [config.color], - ); - return ( - - - - - + + + + + - - - {displayMessage} + + + {message} - {metadata && ( - - - {t("message.activity.details")} - - {isExpanded ? ( - - ) : ( - - )} - - )} - {isExpanded && metadata && ( - - - {JSON.stringify(metadata, null, 2)} - - - )} - + ); }); @@ -2249,8 +2246,6 @@ function taskActivityIcon(activity: TaskActivity) { return CircleDot; case "completed": return Check; - case "reopened": - return RotateCcw; default: return CheckSquare; } @@ -3028,6 +3023,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa } interface ToolCallProps { + agentMessage?: AgentMessage; toolName: string; args?: unknown; result?: unknown; @@ -3047,6 +3043,7 @@ interface ToolCallProps { } export const ToolCall = memo(function ToolCall({ + agentMessage, toolName, args, result, @@ -3087,6 +3084,7 @@ export const ToolCall = memo(function ToolCall({ const presentation = useMemo( () => buildToolCallPresentation({ + agentMessage, toolName, status, error: error ?? null, @@ -3095,7 +3093,7 @@ export const ToolCall = memo(function ToolCall({ cwd, resolveIcon: resolveToolCallIcon, }), - [toolName, status, error, effectiveDetail, metadata, cwd], + [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage], ); const handleOpenFile = useMemo(() => { const openFilePath = presentation.openFilePath; @@ -3108,9 +3106,10 @@ export const ToolCall = memo(function ToolCall({ const handleToggle = useCallback(() => { if (!shouldRenderInline) { openToolCall({ + toolName, displayName: presentation.displayName, summary: presentation.summary, - detail: effectiveDetail, + detail: presentation.detail, errorText: presentation.errorText, icon: presentation.icon, showLoadingSkeleton: presentation.isLoadingDetails, @@ -3121,12 +3120,13 @@ export const ToolCall = memo(function ToolCall({ }, [ shouldRenderInline, openToolCall, + toolName, + presentation.detail, presentation.displayName, presentation.summary, presentation.errorText, presentation.icon, presentation.isLoadingDetails, - effectiveDetail, ]); useEffect(() => { @@ -3161,7 +3161,8 @@ export const ToolCall = memo(function ToolCall({ if (!shouldRenderInline) return null; return ( diff --git a/packages/app/src/components/model-browser-view.test.ts b/packages/app/src/components/model-browser-view.test.ts index b227daead5..028eed8044 100644 --- a/packages/app/src/components/model-browser-view.test.ts +++ b/packages/app/src/components/model-browser-view.test.ts @@ -7,6 +7,7 @@ import { resolveInitialModelBrowserView, resolveModelBrowserAllView, groupProfilesByProviderModel, + resolveModelBrowserScrolling, } from "./model-browser-view"; function provider( @@ -37,6 +38,20 @@ function modelRow( }; } +describe("model browser scrolling", () => { + it("participates in native compact bottom-sheet scrolling", () => { + expect(resolveModelBrowserScrolling({ isNative: true, isCompact: true })).toBe("sheet"); + }); + + it.each([ + { platform: "native wide", isNative: true, isCompact: false }, + { platform: "compact web", isNative: false, isCompact: true }, + { platform: "wide web", isNative: false, isCompact: false }, + ])("owns scrolling on $platform surfaces", ({ isNative, isCompact }) => { + expect(resolveModelBrowserScrolling({ isNative, isCompact })).toBe("independent"); + }); +}); + describe("model browser initial view", () => { const codex = provider("codex", "Codex"); const pi = provider("pi", "Pi"); diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts index 464aa69eff..f5c92afa31 100644 --- a/packages/app/src/components/model-browser-view.ts +++ b/packages/app/src/components/model-browser-view.ts @@ -9,6 +9,16 @@ export type ModelBrowserView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; +export function resolveModelBrowserScrolling({ + isNative, + isCompact, +}: { + isNative: boolean; + isCompact: boolean; +}): "sheet" | "independent" { + return isNative && isCompact ? "sheet" : "independent"; +} + /** A profile's model reference; used to match profiles back to model rows. */ export interface ModelProfileRef { provider: string; diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 422240c21c..17924b233d 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -13,7 +13,10 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { + FlatList as SheetFlatList, + ScrollView as SheetScrollView, +} from "@/components/ui/scroll-view"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { @@ -36,7 +39,7 @@ import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { @@ -154,6 +157,7 @@ interface ModelBrowserInput { } export interface ModelBrowserState { + serverId: string | null; providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; @@ -192,6 +196,7 @@ interface ModelBrowserProps { } interface ModelBrowserContentProps extends Omit { + serverId: string | null; view: ModelBrowserView; providers: ProviderSelectorProvider[]; selectedProvider: string; @@ -209,14 +214,16 @@ type ProviderGlyphTone = "muted" | "foreground"; export function ModelProviderGlyph({ provider, + serverId, size, tone = "muted", }: { provider: string; + serverId: string | null; size: number; tone?: ProviderGlyphTone; }) { - const Icon = getProviderIcon(provider); + const Icon = useProviderIcon(provider, serverId); const color = tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; return ; @@ -330,7 +337,12 @@ export function useModelBrowser({ return { title: view.providerLabel, leading: ( - + ), back: singleProviderView ? undefined : { onPress: showAll }, actions: ( @@ -389,6 +401,7 @@ export function useModelBrowser({ ); return { + serverId, providers, selectedProvider, selectedModel, @@ -612,13 +625,6 @@ function ModelRowProfileAction({ }) { const isCompact = useIsCompactFormFactor(); const visible = hovered || isNative || isCompact; - const handlePress = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - onPress(); - }, - [onPress], - ); const pressableStyle = useCallback( ({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.rowIconButton, @@ -632,7 +638,7 @@ function ModelRowProfileAction({ void; @@ -672,8 +680,8 @@ function ModelRow({ const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const leadingSlot = useMemo( - () => , - [row.provider], + () => , + [row.provider, serverId], ); const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description; @@ -763,7 +771,6 @@ function ModelRow({ const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.browserRow, - styles.browserModelRow, Boolean(hovered) && styles.browserRowHovered, pressed && styles.browserRowPressed, ], @@ -772,7 +779,7 @@ function ModelRow({ return ( @@ -800,16 +807,24 @@ function ModelRow({ ) : null} - {profileAction} + {profileAction ? : null} + {/* The row renders a ); @@ -100,9 +92,9 @@ export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { {error} - {isCompact ? null : retry} + {isCompact ? null : reload} - {isCompact ? {retry} : null} + {isCompact ? {reload} : null} ); } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 6b9290632a..230a41e2e9 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -24,7 +24,7 @@ import { HostStatusDotSlot } from "@/components/hosts/host-picker"; import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { Switch } from "@/components/ui/switch"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { CadenceEditor } from "@/components/schedules/cadence-editor"; import { SelectField, @@ -661,8 +661,8 @@ function ScheduleTargetFields({ [], ); const modelTriggerLeading = useMemo( - () => , - [state.selectedProvider], + () => , + [state.selectedProvider, state.selectedServerId], ); const renderModelTrigger = useCallback( ({ @@ -1019,11 +1019,17 @@ function ThinkingOptionItem({ ); } -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string | null; +}): ReactElement | null { + const Icon = useProviderIcon(provider ?? "", serverId); if (!provider) { return null; } - const Icon = getProviderIcon(provider); return ; } diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index f2799fcf54..2af4b33428 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { StatusBadge } from "@/components/ui/status-badge"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; @@ -22,7 +22,7 @@ import { resolveScheduleTitle, scheduleProductName, } from "@/utils/schedule-format"; -import { formatTimeAgo } from "@/utils/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; // Themed lucide wrappers — module-scope so only the icon re-renders on theme @@ -59,6 +59,7 @@ export interface ScheduleRowActions { } interface ScheduleRowProps extends ScheduleRowActions { + serverId: string; schedule: ScheduleSummary; /** Client-derived target line (agent title / project / shortened path). */ targetLabel: string; @@ -95,16 +96,19 @@ function stateBadge(state: ScheduleDerivedState): { // Meta reads left-to-right as identity → history → future: how often, when it // was created, when it last ran, and (only while it can still run) when it runs // next. Status lives on the badge, never repeated here. -function buildMeta( - schedule: ScheduleSummary, - state: ScheduleDerivedState, - serverName: string | undefined, - singleHost: boolean, -): string { +function buildMeta(input: { + schedule: ScheduleSummary; + state: ScheduleDerivedState; + createdAgo: string; + lastRunAgo: string; + serverName: string | undefined; + singleHost: boolean; +}): string { + const { schedule, state, serverName, singleHost } = input; const parts = [ formatCadence(schedule.cadence), - `Created ${formatTimeAgo(new Date(schedule.createdAt))}`, - schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run", + `Created ${input.createdAgo}`, + schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run", ]; if (state === "active") { const next = formatNextRun(schedule.nextRunAt); @@ -118,13 +122,40 @@ function buildMeta( return parts.join(" · "); } +function ScheduleMeta({ + schedule, + state, + serverName, + singleHost, +}: { + schedule: ScheduleSummary; + state: ScheduleDerivedState; + serverName: string | undefined; + singleHost: boolean; +}) { + const createdAgo = useTimeAgo(new Date(schedule.createdAt)); + const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null); + const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost }); + return ( + + {meta} + + ); +} + /** Small provider glyph. Reads the icon color off a StyleSheet object so the - * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { + * dynamic component (useProviderIcon) stays compliant without useUnistyles. */ +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string; +}): ReactElement | null { + const Icon = useProviderIcon(provider ?? "", serverId); if (!provider) { return null; } - const Icon = getProviderIcon(provider); return ; } @@ -138,6 +169,7 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement * highlights without reflow. */ export function ScheduleRow({ + serverId, schedule, targetLabel, provider, @@ -160,7 +192,6 @@ export function ScheduleRow({ const title = resolveScheduleTitle(schedule); const productName = scheduleProductName(schedule); const badge = stateBadge(state); - const meta = buildMeta(schedule, state, serverName, singleHost ?? false); const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused"); const rowStyle = useCallback( @@ -189,7 +220,7 @@ export function ScheduleRow({ > - + @@ -198,9 +229,12 @@ export function ScheduleRow({ {targetLabel} - - {meta} - + diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index c591b03d42..d91f95cb72 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -129,6 +129,7 @@ function SchedulesTableRow({ return ( ({ color: theme.colors.foreground }); + +interface SettingsCollapsibleRowProps { + label: string; + defaultExpanded?: boolean; + testID?: string; + /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */ + children: ReactNode; +} + +/** + * A settings row that folds a group of rows under it. Collapsed, it is one row: the label and a + * trailing chevron that points right, and turns down once expanded. Sits inside a `SettingsCard` like any + * other row. + */ +export function SettingsCollapsibleRow({ + label, + defaultExpanded = false, + testID, + children, +}: SettingsCollapsibleRowProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + // React Native Web does not map `accessibilityState.expanded` to `aria-expanded`. + const webExpandedProps = useMemo( + () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), + [expanded], + ); + const headerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + settingsStyles.row, + hovered && styles.headerHovered, + pressed && styles.headerPressed, + ], + [], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + + return ( + + + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( + <> + + {label} + + + + + + )} + + {expanded + ? Children.toArray(children).map((child, index) => ( + + {child} + + )) + : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerHovered: { + backgroundColor: theme.colors.surface2, + }, + headerPressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/screens/settings/settings-group.tsx b/packages/app/src/components/settings/headings/settings-group.tsx similarity index 51% rename from packages/app/src/screens/settings/settings-group.tsx rename to packages/app/src/components/settings/headings/settings-group.tsx index d6dee9dab4..e13bc6017a 100644 --- a/packages/app/src/screens/settings/settings-group.tsx +++ b/packages/app/src/components/settings/headings/settings-group.tsx @@ -1,9 +1,7 @@ import { useMemo, type ReactNode } from "react"; -import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; -import { useTranslation } from "react-i18next"; -import { Info } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SettingsInfoTip } from "./settings-info-tip"; interface SettingsGroupProps { title: string; @@ -28,8 +26,6 @@ export function SettingsGroup({ style, children, }: SettingsGroupProps) { - const { t } = useTranslation(); - const { theme } = useUnistyles(); const groupStyle = useMemo(() => [styles.group, style], [style]); return ( @@ -37,22 +33,11 @@ export function SettingsGroup({ {title} {info ? ( - - - - - - - - {info} - - + ) : null} {trailing} @@ -83,14 +68,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - infoButton: { - padding: theme.spacing[1], - marginLeft: -theme.spacing[1], - }, - tooltipText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - maxWidth: 280, - lineHeight: theme.fontSize.base * 1.4, - }, })); diff --git a/packages/app/src/components/settings/headings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx new file mode 100644 index 0000000000..c8d36df74f --- /dev/null +++ b/packages/app/src/components/settings/headings/settings-info-tip.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import { Pressable, Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Info } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +const ThemedInfo = withUnistyles(Info); + +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +interface SettingsInfoTipProps { + /** Header the tip belongs to; used for the accessibility label. */ + title: string; + info: ReactNode; + testID?: string; +} + +/** + * The one way a settings group or section explains itself: an info icon on the + * header that opens a tooltip. Never a paragraph under the header — see + * docs/design.md §7. + */ +export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) { + const { t } = useTranslation(); + return ( + + + + + + + + {info} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + button: { + padding: theme.spacing[1], + marginLeft: -theme.spacing[1], + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + maxWidth: 280, + lineHeight: theme.fontSize.base * 1.4, + }, +})); diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/components/settings/headings/settings-section.tsx similarity index 70% rename from packages/app/src/screens/settings/settings-section.tsx rename to packages/app/src/components/settings/headings/settings-section.tsx index 709b097455..28b19f3589 100644 --- a/packages/app/src/screens/settings/settings-section.tsx +++ b/packages/app/src/components/settings/headings/settings-section.tsx @@ -1,10 +1,16 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { SettingsInfoTip } from "./settings-info-tip"; import { settingsStyles } from "@/styles/settings"; interface SettingsSectionProps { title: string; + /** + * What this section is for. Renders as an info tooltip on the header; a + * paragraph between the header and the card is wrong (docs/design.md §7). + */ + info?: ReactNode; trailing?: ReactNode; testID?: string; style?: StyleProp; @@ -23,6 +29,7 @@ interface SettingsSectionProps { */ export function SettingsSection({ title, + info, trailing, testID, style, @@ -36,7 +43,16 @@ export function SettingsSection({ return ( - {title} + + {title} + {info ? ( + + ) : null} + {trailing} {children} @@ -53,6 +69,11 @@ const styles = StyleSheet.create((theme) => ({ marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, content: { gap: theme.spacing[3], }, diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx new file mode 100644 index 0000000000..f31a0e7b38 --- /dev/null +++ b/packages/app/src/components/settings/index.tsx @@ -0,0 +1,207 @@ +import { + Children, + isValidElement, + useCallback, + useImperativeHandle, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { + SettingsRowProps, + SettingsSwitchProps, + SettingsSelectProps, + SettingsInputProps, + SettingsActionProps, +} from "@getpaseo/plugin/client/ui"; +import type { EditingTextInputHandle } from "@/components/ui/text-input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { FormTextInput } from "@/components/ui/form-field"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { settingsStyles } from "@/styles/settings"; +export { SettingsGroup } from "./headings/settings-group"; +export { SettingsSection } from "./headings/settings-section"; +export { SettingsCollapsibleRow } from "./collapsible-row"; + +interface AppSettingsRowProps extends Omit { + hint?: ReactNode; + labelAccessory?: ReactNode; +} + +export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { + return ( + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + ); +} + +export function SettingsRow({ + label, + labelAccessory, + hint, + error, + children, + testID, +}: AppSettingsRowProps) { + const compact = useIsCompactFormFactor(); + const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + return ( + + + {labelAccessory ? ( + + {label} + {labelAccessory} + + ) : ( + {label} + )} + {typeof hint === "string" ? {hint} : hint} + {error ? ( + + {error} + + ) : null} + + {children ? {children} : null} + + ); +} + +export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) { + return ( + + + + ); +} + +function SettingsOption({ + option, + selected, + onValueChange, +}: { + option: { label: string; value: Value }; + selected: boolean; + onValueChange(value: Value): void; +}) { + const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]); + return ( + + {option.label} + + ); +} + +export function SettingsSelect({ + value, + options, + onValueChange, + disabled, + ...row +}: SettingsSelectProps) { + const selectedLabel = options.find((option) => option.value === value)?.label ?? value; + return ( + + + + {selectedLabel} + + + {options.map((option) => ( + + ))} + + + + ); +} + +export function SettingsInput({ + initialValue, + onChangeText, + placeholder, + disabled, + secureTextEntry, + ref, + ...row +}: SettingsInputProps) { + const compact = useIsCompactFormFactor(); + const input = useRef(null); + useImperativeHandle( + ref, + () => ({ + focus: () => input.current?.focus(), + blur: () => input.current?.blur(), + getText: () => input.current?.getText() ?? "", + replaceText: (text) => input.current?.replaceText(text), + }), + [], + ); + return ( + + + + ); +} + +export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compactRow: { flexWrap: "wrap", gap: theme.spacing[3] }, + label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] }, + labelRow: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + }, + accessoryLabel: { flexShrink: 1, minWidth: 0 }, + control: { flexShrink: 1, maxWidth: "100%" }, + input: { minWidth: 180 }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index ae8bf82777..73c008dd22 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -31,7 +31,6 @@ import { type SidebarProjectEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list"; -import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { getHostRuntimeStore, type HostRuntimeController, @@ -39,7 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -168,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedRuntimeWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], @@ -404,35 +403,6 @@ describe("sidebar workspace render isolation", () => { }); }); - it("does not re-render for a deep-equal scripts patch", async () => { - const counts: RenderCounts = { - frame: 0, - headers: {}, - rows: {}, - projectSelection: {}, - rowSelection: {}, - }; - ({ root, container } = await renderProbe(counts)); - - const applyRunningScript = (current: Parameters[0]) => - patchWorkspaceScripts(current, { - workspaceId: "a-main", - scripts: [{ ...runningScript }], - }); - - act(() => { - useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript); - }); - - expect(counts).toEqual({ - frame: 0, - headers: {}, - rows: {}, - projectSelection: {}, - rowSelection: {}, - }); - }); - it("updates active selection probes from the active workspace route", async () => { const counts: RenderCounts = { frame: 0, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 20ba99e847..bbda666436 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -9,8 +9,6 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { useMutation } from "@tanstack/react-query"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, useCallback, @@ -32,19 +30,14 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { type GestureType } from "react-native-gesture-handler"; -import * as Clipboard from "expo-clipboard"; -import { - ExternalLink, - GitPullRequest, - Settings, - MoreVertical, - Plus, - Trash2, -} from "lucide-react-native"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; +import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; @@ -71,11 +64,7 @@ import { type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; -import { - hasActiveSidebarLabelFilter, - useSidebarViewStore, - type SidebarGroupMode, -} from "@/stores/sidebar-view-store"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenu, @@ -126,13 +115,14 @@ import { SidebarWorkspaceTrailingContent, useSidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; +import { DensityIcon } from "@/components/ui/density-icon"; import { PressHighlight } from "@/components/ui/press-highlight"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, @@ -142,7 +132,6 @@ import { } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { getCurrentProjectRemoveReadiness, @@ -159,6 +148,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import type { HostBadgeModel } from "@/hosts/appearance"; import { useHostBadges } from "@/hosts/use-host-badges"; import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model"; +import { PullRequestStateIcon } from "@/git/pull-request-state-icon"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; @@ -166,7 +156,6 @@ const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKe const WORKSPACE_STATUS_DOT_WIDTH = 14; const ThemedExternalLink = withUnistyles(ExternalLink); -const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedPlus = withUnistyles(Plus); const ThemedMoreVertical = withUnistyles(MoreVertical); @@ -179,26 +168,6 @@ const foregroundColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const redColorMapping = (theme: Theme) => ({ - color: theme.colors.statusDanger, -}); -const greenColorMapping = (theme: Theme) => ({ - color: theme.colors.statusSuccess, -}); -const purpleColorMapping = (theme: Theme) => ({ - color: theme.colors.statusMerged, -}); - -function getPrIconUniMapping(state: PrHint["state"]) { - switch (state) { - case "merged": - return purpleColorMapping; - case "open": - return greenColorMapping; - case "closed": - return redColorMapping; - } -} function isWorkspaceSelected(input: { selection: ActiveWorkspaceSelection | null; @@ -257,13 +226,14 @@ interface SidebarWorkspaceListProps { onRefresh?: () => void; onWorkspacePress?: () => void; onAddProject?: () => void; + onImportSession?: () => void; listFooterComponent?: ReactElement | null; // Rendered inside the scroll area, below the Pinned section and above the workspace // list. Holds the "Workspaces" section header so pinned items sit above it. listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; } interface ProjectHeaderRowProps { @@ -312,6 +282,7 @@ interface WorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -352,7 +323,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp {isHovered ? ( - + ) : ( - + )} {hint.number} @@ -641,6 +611,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -660,6 +631,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -671,7 +643,7 @@ function WorkspaceRowRightGroup({ const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -693,7 +665,7 @@ function WorkspaceRowRightGroup({ ) : null} {renderSlot ? ( - + ( <> - @@ -1291,42 +1271,14 @@ function WorkspaceRowWithMenu({ archiveController.archive(); }, [archiveController, isArchiving]); + const clipboard = useWorkspaceClipboardActions(); const handleCopyPath = useCallback(() => { - let copyTargetDirectory: string; - try { - copyTargetDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("sidebar.workspace.toasts.workspacePathUnavailable"), - ); - return; - } - void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied(t("sidebar.workspace.toasts.pathCopied")); - }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); + clipboard.copyPath(workspace); + }, [clipboard, workspace]); const handleCopyBranchName = useCallback(() => { - if (!workspace.currentBranch) { - return; - } - void Clipboard.setStringAsync(workspace.currentBranch); - toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); - }, [t, toast, workspace.currentBranch]); - - const renameMutation = useMutation({ - mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); - } - await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); - }, - }); + clipboard.copyBranchName(workspace); + }, [clipboard, workspace]); const handleOpenRename = useCallback(() => { setIsRenameOpen(true); @@ -1336,13 +1288,6 @@ function WorkspaceRowWithMenu({ setIsRenameOpen(false); }, []); - const handleSubmitRename = useCallback( - async (value: string) => { - await renameMutation.mutateAsync(value.trim()); - }, - [renameMutation], - ); - const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); @@ -1350,15 +1295,21 @@ function WorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -1396,19 +1347,16 @@ function WorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} /> - @@ -1609,7 +1557,7 @@ function ProjectBlock({ isDragging, dragHandleProps, useNestable, - dragGestureHostPresented, + dragGestureHostActive, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, @@ -1634,7 +1582,7 @@ function ProjectBlock({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -1814,7 +1762,7 @@ function ProjectBlock({ useDragHandle nestable={useNestable} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} containerStyle={styles.workspaceListContainer} /> {canToggleWorkspaces ? ( @@ -1898,7 +1846,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previous.useNestable === next.useNestable && - previous.dragGestureHostPresented === next.dragGestureHostPresented && + previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && areProjectBlockSelectionsEqual(previous, next) ); @@ -1948,10 +1896,11 @@ export function SidebarWorkspaceList({ onRefresh: _onRefresh, onWorkspacePress, onAddProject, + onImportSession, listFooterComponent, listHeaderComponent, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, }: SidebarWorkspaceListProps) { const pathname = usePathname(); const hosts = useHosts(); @@ -1969,9 +1918,6 @@ export function SidebarWorkspaceList({ const onToggleWorkspacePin = useSidebarWorkspacePinController(); const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder); const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder); - const hasActiveLabelFilter = useSidebarViewStore((state) => - hasActiveSidebarLabelFilter(state.labelFilter), - ); const handlePinnedWorkspaceReorder = useCallback( (reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); @@ -2003,11 +1949,10 @@ export function SidebarWorkspaceList({ // this whole subtree, which unmounted the header — and the header is where the display menu's // trigger lives, so filtering the last row away closed the menu you were filtering from. // - // Only the label filter can get here. The project filter resolves against the projects it can - // see and falls back to "all projects" when nothing matches, so it either keeps at least one - // project or is not applied at all — it can narrow this list but never empty it. - const sidebarFilterEmpty = - hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0; + // Background visibility can also hide the last project. Keep the header mounted + // whenever a local filter empties an otherwise populated directory, so users can + // reach the display menu to reveal their work again. + const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0; // Project mode is the one that keeps its project headers; every other grouping mode is a flat // list of grouped rows, so a new mode lands in the grouped branch rather than silently in this @@ -2028,7 +1973,7 @@ export function SidebarWorkspaceList({ listHeaderComponent={listHeaderComponent} sidebarFilterEmpty={sidebarFilterEmpty} parentGestureRef={parentGestureRef} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={dragGestureHostActive} /> ) : ( ; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; }) { const showShortcutBadges = useShowShortcutBadges(); const pinnedWorkspaces = useMemo( @@ -2121,7 +2067,7 @@ function SidebarGroupedModeList({ listHeaderComponent={listHeaderComponent} sidebarFilterEmpty={sidebarFilterEmpty} parentGestureRef={parentGestureRef} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={dragGestureHostActive} /> ); } @@ -2136,12 +2082,13 @@ function ProjectModeList({ shortcutIndexByWorkspaceKey, onWorkspacePress, onAddProject, + onImportSession, listFooterComponent, listHeaderComponent, sidebarFilterEmpty, hasActiveProjectFilter, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, pathname, hostBadgeByServerId, supportsMultiplicityByServerId, @@ -2358,7 +2305,7 @@ function ProjectModeList({ isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} useNestable={platformIsNative} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={dragGestureHostActive} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostBadgeByServerId={hostBadgeByServerId} @@ -2380,7 +2327,7 @@ function ProjectModeList({ onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, @@ -2444,7 +2391,7 @@ function ProjectModeList({ const projectBody = projects.length === 0 ? ( - + ) : ( ); @@ -2480,7 +2427,7 @@ function ProjectModeList({ useDragHandle nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} containerStyle={styles.workspaceListContainer} /> {canTogglePinnedChats ? ( @@ -2575,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({ // the step in reads as belonging to that project. Padding rather than margin, so the hover and // pressed fills stay the same box as every other row in the sidebar. newWorkspaceGhostRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[4], @@ -2595,27 +2542,27 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it. newWorkspaceGhostIconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, color: theme.colors.foreground, }, projectRow: { position: "relative", - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -2656,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -2737,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({ right: theme.spacing[2], }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 018c8cc37e..d62b3f9ff1 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { sheetTitle={t("sidebar.display.heading")} testID="sidebar-display-preferences-content" > + + {t("sidebar.display.showBackground")} + ; export interface SidebarDisplayPreferences { + showBackground: boolean; + toggleBackground: () => void; grouping: SidebarGroupMode; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; @@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences { * this for a value and set it; where it lands is this module's problem. */ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { + const showBackground = useSidebarViewStore((state) => state.showBackground); + const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { return useMemo( () => ({ + showBackground, + toggleBackground, grouping, setGrouping, titleSource: workspaceTitleSource, @@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { clearLabelFilter, }), [ + showBackground, + toggleBackground, grouping, setGrouping, workspaceTitleSource, diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx index 1a0e4ecf0a..3328ed2c5c 100644 --- a/packages/app/src/components/sidebar/empty-states.tsx +++ b/packages/app/src/components/sidebar/empty-states.tsx @@ -1,11 +1,33 @@ -import { useCallback } from "react"; -import { Plus } from "lucide-react-native"; +import { type ReactNode, useCallback } from "react"; +import { Import, Plus } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; +function SidebarEmptyStateCard({ + testID, + title, + description, + children, +}: { + testID: string; + title: string; + description: string; + children: ReactNode; +}) { + return ( + + + {title} + {description} + + {children} + + ); +} + /** * The two things the sidebar's workspace list says when it has no rows. * @@ -27,51 +49,66 @@ export function SidebarFilterEmptyState() { }, [clearLabelFilter, clearProjectFilters]); return ( - - {t("sidebar.filterEmpty.title")} - {t("sidebar.filterEmpty.description")} - - + ); } -export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () => void }) { +export function SidebarProjectEmptyState({ + onAddProject, + onImportSession, +}: { + onAddProject?: () => void; + onImportSession?: () => void; +}) { const { t } = useTranslation(); return ( - - {t("sidebar.project.empty.title")} - {t("sidebar.project.empty.description")} - - + + ); } const styles = StyleSheet.create((theme) => ({ - container: { - marginHorizontal: theme.spacing[2], - marginTop: theme.spacing[4], - paddingTop: theme.spacing[6], - paddingBottom: theme.spacing[4], - paddingHorizontal: theme.spacing[4], + card: { + marginTop: theme.spacing[3], + padding: theme.spacing[4], + borderWidth: 1, + borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface0, - alignItems: "center", - gap: theme.spacing[3], + }, + copy: { + gap: theme.spacing[1], + }, + actions: { + flexDirection: "row", + gap: theme.spacing[2], + marginTop: theme.spacing[4], }, title: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - textAlign: "center", + fontWeight: theme.fontWeight.normal, }, description: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - textAlign: "center", }, })); diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx index 9dc2c9fdba..2169492a08 100644 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ b/packages/app/src/components/sidebar/pinned-section-header.tsx @@ -2,16 +2,11 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight } from "lucide-react-native"; import { Pressable, Text } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; -import type { Theme } from "@/styles/theme"; - -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); +import { density, densityFontSize } from "@/styles/density"; export function PinnedSectionHeader({ collapsed, @@ -23,7 +18,6 @@ export function PinnedSectionHeader({ const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown; return ( {t("sidebar.pinned.title")} {hovered || isNative || isCompact ? ( - + ) : null} )} @@ -47,7 +45,7 @@ export function PinnedSectionHeader({ const styles = StyleSheet.create((theme) => ({ header: { - minHeight: 36, + minHeight: density.rowHeight, flexDirection: "row", alignItems: "center", alignSelf: "flex-start", @@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({ }, title: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: densityFontSize(theme, "sm"), fontWeight: theme.fontWeight.normal, }, })); diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index c3950efe2d..3f31181220 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -1,9 +1,11 @@ import { ActivityIndicator, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getProjectStatusBadgeContent, @@ -34,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4; // // The filled alert occupies the full badge shell so needs-input remains more prominent than // the passive status dots. -// Matches the workspace title's lineHeight (sidebar-workspace-row-content's -// workspaceBranchText) so the icon centers on the title rather than floating above it. -const LEADING_SLOT_HEIGHT = 20; +const DensityProjectIconView = withUnistyles(ProjectIconView); +const projectIconProps = densityIconProps("md", () => ({})); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); @@ -170,6 +171,7 @@ function ProjectStatusBadge({ statusBucket: SidebarStateBucket; backdrop: SidebarSurfaceBackdrop; }) { + const { t } = useTranslation(); // Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout, // so nesting it inside would clip it against the very thing that was meant to separate it from // the icon. It anchors to the same corner instead, growing around the centre the dot had. @@ -177,7 +179,7 @@ function ProjectStatusBadge({ return ( @@ -188,7 +190,7 @@ function ProjectStatusBadge({ return ( @@ -228,12 +230,12 @@ function ProjectIcon({ projectViewKey: string; }) { return ( - ); } @@ -269,12 +271,12 @@ const styles = StyleSheet.create((theme) => { return { // The slot is as tall as the title's line box, not as tall as the icon, and centers the - // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a - // 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab — - // which is why the workspace status indicator is also 20 tall. Keep the two in step. + // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot + // only as tall as the icon puts it above the title and the kebab — which is why the + // workspace status indicator shares the same `rowLineHeight`. projectLeadingVisualSlot: { - width: theme.iconSize.md, - height: LEADING_SLOT_HEIGHT, + width: densityIconBox("md"), + height: density.rowLineHeight, flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -282,8 +284,8 @@ const styles = StyleSheet.create((theme) => { // Anchors the corner badge to the icon rather than to the taller slot. projectIconBox: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), }, projectIconFallbackText: { fontSize: 9, diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx index f67bd738ba..3d437ae0ca 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -2,19 +2,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, ChevronUp } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; -import type { Theme } from "@/styles/theme"; - -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); -const foregroundColorMapping = (theme: Theme) => ({ - color: theme.colors.foreground, -}); -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronUp = withUnistyles(ChevronUp); +import { DensityIcon } from "@/components/ui/density-icon"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; /** * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so @@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({ {({ hovered, pressed }) => ( <> - {expanded ? ( - - ) : ( - - )} + {label} @@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({ const styles = StyleSheet.create((theme) => ({ // Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx. row: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], @@ -106,21 +92,21 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it rather than two points to their left. iconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, }, text: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, textHovered: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 22d48c2c4e..7001bf66a6 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,20 +1,18 @@ -import { useCallback, useMemo } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { LucideIcon } from "lucide-react-native"; +import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; -import { ICON_SIZE } from "@/styles/theme"; -import type { Theme } from "@/styles/theme"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { ShortcutKey } from "@/utils/format-shortcut"; -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +type SidebarHeaderRowVariant = "header" | "compact" | "inline"; -type SidebarHeaderRowVariant = "header" | "compact"; +export type SidebarRowIcon = ComponentType<{ size: number; color: string }>; interface SidebarHeaderRowProps { - icon: LucideIcon; + icon: SidebarRowIcon | null; label: string; onPress: () => void; isActive?: boolean; @@ -24,15 +22,24 @@ interface SidebarHeaderRowProps { /** * "header" (default): a sidebar-height row with its own bottom separator — * the lone header at the top of a sidebar (settings "Back to workspace"). - * "compact": a workspace-row-height row with no separator, for entries that + * "compact": a row with no separator, for entries that * sit in a header group whose wrapper owns the single divider. + * "inline": a full-width row with no inset, for a row inside a padded container such as the + * sidebar footer. */ variant?: SidebarHeaderRowVariant; + /** Shown in the right slot while the row is hovered, when `trailing` is not set. */ shortcutKeys?: ShortcutKey[][] | null; + /** + * The right slot. A sibling of the row's button, never inside it (web cannot nest buttons). A + * press on the slot presses the row; a button inside it presses on its own. + */ + trailing?: ReactNode; + rowRef?: Ref; } export function SidebarHeaderRow({ - icon: Icon, + icon, label, onPress, isActive = false, @@ -41,72 +48,60 @@ export function SidebarHeaderRow({ accessibilityLabel, variant = "header", shortcutKeys = null, + trailing, + rowRef, }: SidebarHeaderRowProps) { - const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const isHighlighted = isHovered || isActive; + const iconSize = variant === "header" ? "md" : "sm"; - const containerStyle = useMemo( - () => (variant === "compact" ? styles.containerCompact : styles.container), - [variant], - ); - - const buttonStyle = useCallback( - ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.button, - variant === "compact" && styles.buttonCompact, - (Boolean(hovered) || isActive) && styles.buttonHovered, - ], - [isActive, variant], - ); - - const renderChildren = useCallback( - (state: PressableStateCallbackType & { hovered?: boolean }) => { - const isHighlighted = Boolean(state.hovered) || isActive; - return ( - <> - - - {shortcutKeys && Boolean(state.hovered) ? ( - - ) : null} - - ); - }, - [ThemedIcon, isActive, label, shortcutKeys], - ); + let right = trailing ?? null; + if (right === null && shortcutKeys && isHovered) { + right = ; + } return ( - - + - {renderChildren} - + + {icon ? ( + + ) : ( + + )} + {label} + + {right === null ? null : ( + + {right} + + )} + ); } -function SidebarHeaderRowLabel({ - label, - isHighlighted, -}: { - label: string; - isHighlighted: boolean; -}) { - const labelStyle = useMemo( - () => [styles.label, isHighlighted && styles.labelHighlighted], - [isHighlighted], - ); - return {label}; -} +const SELECTED_STATE = { selected: true } as const; const styles = StyleSheet.create((theme) => ({ container: { @@ -125,38 +120,60 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", userSelect: "none", }, + containerInline: { + width: "100%", + justifyContent: "center", + userSelect: "none", + }, + row: { + flexDirection: "row", + alignItems: "center", + // Same row geometry as the settings sidebar items. Shorter than the header + // strip so the hover highlight clears the strip's bottom separator. + minHeight: density.tightRowHeight, + borderRadius: theme.borderRadius.lg, + }, + rowHighlighted: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, button: { + flex: 1, + minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - // Match the sidebar workspace-row shape (height, padding, radius) so the - // compact header entries sit tight against the workspace list below. - minHeight: 36, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - // Compact header entries (New workspace / History) sit tighter than the - // workspace-row shape the base button mirrors. - buttonCompact: { - minHeight: 32, - paddingVertical: theme.spacing[1.5], + minHeight: density.tightRowHeight, + paddingVertical: theme.spacing[1], // Match the project rows' inner padding so the icons align on one vertical - // edge with the workspace list below (base button uses a wider spacing[3]). + // edge with the list below. paddingHorizontal: theme.spacing[2], }, - buttonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, + iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") }, + iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") }, label: { - fontSize: theme.fontSize.base, + flexShrink: 1, + fontSize: densityFontSize(theme, "base"), fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, labelHighlighted: { color: theme.colors.foreground, }, - shortcut: { - marginLeft: "auto", + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingRight: theme.spacing[2], }, })); + +function getContainerStyle(variant: SidebarHeaderRowVariant) { + switch (variant) { + case "header": + return styles.container; + case "compact": + return styles.containerCompact; + case "inline": + return styles.containerInline; + } +} diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index eb00df6e12..97ff3d2772 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -14,28 +14,28 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; +import { density } from "@/styles/density"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { resolveAppVersion } from "@/utils/app-version"; +import { openChangelog } from "@/changelog"; import { openExternalUrl } from "@/utils/open-external-url"; const DISCORD_URL = "https://discord.gg/jz8T2uahpH"; const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; -const CHANGELOG_URL = "https://paseo.sh/changelog"; const ThemedActivity = withUnistyles(Activity); -const ThemedCircleHelp = withUnistyles(CircleHelp); const ThemedGift = withUnistyles(Gift); const ThemedKeyboard = withUnistyles(Keyboard); const ThemedDiscordIcon = withUnistyles(DiscordIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon); -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); @@ -97,10 +97,6 @@ export function SidebarHelpMenu() { void openExternalUrl(GITHUB_ISSUE_URL); }, []); - const openChangelog = useCallback(() => { - void openExternalUrl(CHANGELOG_URL); - }, []); - return ( @@ -113,9 +109,10 @@ export function SidebarHelpMenu() { accessibilityLabel={t("sidebar.help.trigger")} > {({ hovered }) => ( - )} @@ -186,8 +183,8 @@ export function SidebarHelpMenu() { const styles = StyleSheet.create((theme) => ({ trigger: { - width: 28, - height: 28, + width: density.iconButton, + height: density.iconButton, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index a09c9c4526..ec834aee19 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; import { useSidebarWorkspacesList, @@ -54,7 +55,9 @@ export function SidebarModelProvider({ active?: boolean; children: ReactNode; }) { - const list = useSidebarWorkspacesList(); + const { t } = useTranslation(); + const list = useSidebarWorkspacesList({ enabled: active }); + const showBackground = useSidebarViewStore((state) => state.showBackground); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); const projectFilters = useSidebarViewStore((state) => state.projectFilters); @@ -105,9 +108,12 @@ export function SidebarModelProvider({ workspaces: [...workspaceEntriesByKey.values()], projectFilters: resolvedProjectFilters, }); - const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter }); + const filtered = filterWorkspacesByLabels({ + workspaces: byProject.filter((workspace) => showBackground || !workspace.background), + ...labelFilter, + }); return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace])); - }, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); + }, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); const visibleWorkspaceKeys = useMemo( () => new Set(filteredWorkspaceEntriesByKey.keys()), [filteredWorkspaceEntriesByKey], @@ -122,16 +128,19 @@ export function SidebarModelProvider({ const included = new Set(resolvedProjectFilters); projects = projects.filter((project) => included.has(project.viewKey)); } - if (hasActiveLabelFilter) { + if (hasActiveLabelFilter || !showBackground) { projects = projects.flatMap((project) => { const workspaces = project.workspaces.filter((workspace) => visibleWorkspaceKeys.has(workspace.workspaceKey), ); - return workspaces.length > 0 ? [{ ...project, workspaces }] : []; + return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) + ? [{ ...project, workspaces }] + : []; }); } return projects; }, [ + showBackground, hasActiveLabelFilter, hasActiveProjectFilter, resolvedProjectFilters, @@ -150,6 +159,7 @@ export function SidebarModelProvider({ pinnedCollapsed, collapsedProjectKeys, collapsedWorkspaceGroupKeys, + t, }), [ collapsedProjectKeys, @@ -161,6 +171,7 @@ export function SidebarModelProvider({ pinnedKeys, pinnedWorkspaceOrder, filteredWorkspaceEntriesByKey, + t, ], ); const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]); diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx new file mode 100644 index 0000000000..425280207e --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -0,0 +1,181 @@ +import { router, usePathname } from "expo-router"; +import { CalendarClock, History, Plus, Search } from "lucide-react-native"; +import { memo, useCallback, useMemo, useRef, type ComponentType } from "react"; +import { useTranslation } from "react-i18next"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { PluginSidebarItem } from "@/plugins/sidebar-items"; +import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; +import { useHostFeature } from "@/runtime/host-features"; +import { + builtinSidebarNavLabelKey, + builtinSidebarNavShortcutAction, + type BuiltinSidebarNavId, +} from "@/sidebar-nav/model"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useWorkspace } from "@/stores/session-store-hooks"; +import { + buildNewWorkspaceRoute, + buildSchedulesRoute, + buildSessionsRoute, +} from "@/utils/host-routes"; + +interface SidebarNavRowProps { + onBeforeNavigate?: () => void; +} + +interface SidebarNavRowsProps extends SidebarNavRowProps { + /** Style for the group wrapper, which the sidebar owns. */ + style?: StyleProp; +} + +/** + * Top-level sidebar navigation, ordered and filtered by the user's + * `sidebarNavItems` preference. Renders nothing — not even the bordered group + * wrapper — when every item is hidden. + */ +export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) { + const { items } = useSidebarNavItems("header"); + const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]); + const groupRef = useRef(null); + + if (visibleItems.length === 0) return null; + + return ( + + {visibleItems.map((item) => { + if (item.kind === "plugin") { + return ( + + ); + } + const Row = BUILTIN_ROWS[item.id]; + return ; + })} + + ); +} + +const SidebarNewWorkspaceRow = memo(function SidebarNewWorkspaceRow({ + onBeforeNavigate, +}: SidebarNavRowProps) { + const { t } = useTranslation(); + const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("new-workspace")); + const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null; + const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null; + const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId); + const supportsWorkspaceMultiplicity = useHostFeature( + activeWorkspaceServerId, + "workspaceMultiplicity", + ); + const canUseActiveWorkspaceContext = Boolean( + activeWorkspace && + (supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)), + ); + + const handlePress = useCallback(() => { + onBeforeNavigate?.(); + router.push( + activeWorkspaceServerId + ? buildNewWorkspaceRoute( + activeWorkspace && canUseActiveWorkspaceContext + ? { + serverId: activeWorkspaceServerId, + sourceDirectory: activeWorkspace.projectRootPath, + projectId: activeWorkspace.projectId, + } + : { serverId: activeWorkspaceServerId }, + ) + : buildNewWorkspaceRoute(), + ); + }, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]); + + return ( + + ); +}); + +function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) { + const { t } = useTranslation(); + const pathname = usePathname(); + const handlePress = useCallback(() => { + onBeforeNavigate?.(); + router.push(buildSessionsRoute()); + }, [onBeforeNavigate]); + + return ( + + ); +} + +function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) { + const { t } = useTranslation(); + const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search")); + const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); + const handlePress = useCallback(() => { + onBeforeNavigate?.(); + setCommandCenterOpen(true); + }, [onBeforeNavigate, setCommandCenterOpen]); + + return ( + + ); +} + +function SidebarSchedulesRow({ onBeforeNavigate }: SidebarNavRowProps) { + const { t } = useTranslation(); + const pathname = usePathname(); + const handlePress = useCallback(() => { + onBeforeNavigate?.(); + router.push(buildSchedulesRoute()); + }, [onBeforeNavigate]); + + return ( + + ); +} + +const BUILTIN_ROWS: Record> = { + "new-workspace": SidebarNewWorkspaceRow, + history: SidebarHistoryRow, + search: SidebarSearchRow, + schedules: SidebarSchedulesRow, +}; diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx new file mode 100644 index 0000000000..e57f3cf6ac --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -0,0 +1,98 @@ +import { useCallback, type ReactNode, type RefObject } from "react"; +import type { View } from "react-native"; +import { MenuRoot, useMenuContext } from "@/components/ui/menu"; +import { PluginPopoverSurface } from "@/plugins/popover"; +import type { SidebarSection } from "@/sidebar-nav/model"; + +/** + * The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on + * compact ones. Plugin items open it this way: + * `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to, + * and `SidebarPopoverSurface` for the body. + */ +export function SidebarPopoverRoot({ + open, + onOpenChange, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function assignRef(ref: RefObject, node: View | null) { + Object.assign(ref, { current: node }); +} + +/** + * Where the popover anchors. `anchorTo` moves it to an element, such as the row just pressed; + * `offerAnchor` sets it only when nothing is anchored; `releaseAnchor` drops an element that is + * going away; `anchorToFallback` covers an item that anchored nothing. + */ +export function useSidebarPopoverAnchor(name: string): { + anchorTo: (node: View | null) => void; + offerAnchor: (node: View | null) => void; + releaseAnchor: (node: View | null) => void; + anchorToFallback: (fallback: RefObject) => void; +} { + const { triggerRef } = useMenuContext(name); + const anchorTo = useCallback((node: View | null) => assignRef(triggerRef, node), [triggerRef]); + const offerAnchor = useCallback( + (node: View | null) => { + if (!triggerRef.current) assignRef(triggerRef, node); + }, + [triggerRef], + ); + const releaseAnchor = useCallback( + (node: View | null) => { + if (triggerRef.current === node) assignRef(triggerRef, null); + }, + [triggerRef], + ); + const anchorToFallback = useCallback( + (fallback: RefObject) => offerAnchor(fallback.current), + [offerAnchor], + ); + return { anchorTo, offerAnchor, releaseAnchor, anchorToFallback }; +} + +const PLACEMENT = { + header: { side: "right", align: "start" }, + footer: { side: "top", align: "start" }, +} as const satisfies Record; + +export function SidebarPopoverSurface({ + section, + title, + sheetTrailing, + testID, + children, +}: { + section: SidebarSection; + /** The bottom sheet's title. */ + title: string; + /** Controls on the right of the bottom sheet's title. */ + sheetTrailing?: ReactNode; + testID: string; + children: ReactNode; +}) { + const placement = PLACEMENT[section]; + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 55882a0c49..c2dc31f2ee 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, @@ -85,6 +86,7 @@ function projectionInput(options?: { pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), collapsedWorkspaceGroupKeys: new Set(), + t: i18n.t, }; } diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index c67042b45e..2e84f02aae 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { splitPinnedSidebarGroups, @@ -45,6 +46,7 @@ export interface SidebarProjectionInput { pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; collapsedWorkspaceGroupKeys: ReadonlySet; + t: TFunction; } export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection { @@ -100,7 +102,7 @@ function buildWorkspaceGroups( return []; case "status": return statusWorkspaceGroups( - buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey), + buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t), ); } } diff --git a/packages/app/src/components/sidebar/sidebar-separator.tsx b/packages/app/src/components/sidebar/sidebar-separator.tsx index 864e56361f..30310d84ad 100644 --- a/packages/app/src/components/sidebar/sidebar-separator.tsx +++ b/packages/app/src/components/sidebar/sidebar-separator.tsx @@ -6,8 +6,8 @@ import { StyleSheet } from "react-native-unistyles"; * padded container so the line runs the full sidebar width — matching the * separator beneath the workspace sidebar's "New agent" header. */ -export function SidebarSeparator() { - return ; +export function SidebarSeparator({ testID }: { testID?: string } = {}) { + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 2c37650674..85c0d524f8 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; +import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -40,22 +40,20 @@ import { CircleX, } from "lucide-react-native"; import { useToast } from "@/contexts/toast-context"; -import { useMutation } from "@tanstack/react-query"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; -import * as Clipboard from "expo-clipboard"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, resolveTrailingActionVisibility, + type SidebarWorkspaceTrailingPresentation, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, @@ -84,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; // Themed icon wrappers -const foregroundMutedColorMapping = (theme: Theme) => ({ +const mutedGroupIconProps = densityIconProps("sm", (theme) => ({ color: theme.colors.foregroundMuted, -}); +})); // One mapping per bucket, resolved through the status-dot producer so a group header and // the rows under it cannot disagree about what "failed" looks like. -const needsInputColorMapping = (theme: Theme) => ({ +const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); -const failedColorMapping = (theme: Theme) => ({ +})); +const failedGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, -}); -const attentionColorMapping = (theme: Theme) => ({ +})); +const attentionGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, -}); -const runningColorMapping = (theme: Theme) => ({ +})); +const runningGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, -}); +})); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); @@ -129,7 +127,7 @@ interface StatusWorkspaceListProps { /** Swaps the group list for the label filter's empty state. Never the header above it. */ sidebarFilterEmpty?: boolean; parentGestureRef?: MutableRefObject; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; } export function SidebarStatusWorkspaceList({ @@ -146,7 +144,7 @@ export function SidebarStatusWorkspaceList({ listHeaderComponent, sidebarFilterEmpty = false, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, }: StatusWorkspaceListProps) { const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, @@ -217,7 +215,7 @@ export function SidebarStatusWorkspaceList({ useDragHandle nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} /> {canTogglePinnedWorkspaces ? ( state.toggleWorkspaceGroupCollapsed, @@ -433,7 +432,7 @@ function StatusGroupHeader({ ; } if (collapsed) { - return ; + return ; } - return ; + return ; } function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) { switch (bucket) { case "needs_input": - return ; + return ; case "failed": - return ; + return ; case "attention": - return ; + return ; case "running": - return ; + return ; case "done": - return ; + return ; } } @@ -626,42 +625,17 @@ function StatusWorkspaceRowWithMenu({ archiveController.archive(); }, [archiveController, isArchiving]); + const clipboard = useWorkspaceClipboardActions(); const handleCopyPath = useCallback(() => { - let copyTargetDirectory: string; - try { - copyTargetDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Workspace path not available"); - return; - } - void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied("Path copied"); - }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); + clipboard.copyPath(workspace); + }, [clipboard, workspace]); const handleCopyBranchName = useCallback(() => { - void Clipboard.setStringAsync(workspace.name); - toast.copied("Branch name copied"); - }, [toast, workspace.name]); - - const renameMutation = useMutation({ - mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); - await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); - }, - }); + clipboard.copyBranchName(workspace); + }, [clipboard, workspace]); const handleOpenRename = useCallback(() => setIsRenameOpen(true), []); const handleCloseRename = useCallback(() => setIsRenameOpen(false), []); - const handleSubmitRename = useCallback( - async (value: string) => { - await renameMutation.mutateAsync(value.trim()); - }, - [renameMutation], - ); const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); @@ -669,15 +643,21 @@ function StatusWorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -710,6 +690,7 @@ function StatusWorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} @@ -719,14 +700,10 @@ function StatusWorkspaceRowWithMenu({ isDragging={isDragging} dragHandleProps={dragHandleProps} /> - @@ -751,6 +728,7 @@ interface StatusWorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -797,6 +775,7 @@ function StatusWorkspaceRowInnerContent({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, isPinned, onTogglePin, @@ -851,7 +830,7 @@ function StatusWorkspaceRowInnerContent({ {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -892,6 +871,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -929,7 +909,7 @@ function StatusWorkspaceRowInnerContent({ workspace={workspace} backdrop={backdrop} trailing={trailing} - showBase={showTrailing} + trailingPresentation={trailingPresentation} showKebab={showKebabInSlot} showScrim={showScrim} reserveSlotWidth={reserveSlotWidth} @@ -939,6 +919,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -959,7 +940,7 @@ function StatusWorkspaceActionSlot({ workspace, backdrop, trailing, - showBase, + trailingPresentation, showKebab, showScrim, reserveSlotWidth, @@ -969,6 +950,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -978,7 +960,7 @@ function StatusWorkspaceActionSlot({ workspace: SidebarWorkspaceEntry; backdrop: SidebarSurfaceBackdrop; trailing: SidebarWorkspaceTrailing; - showBase: boolean; + trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; showScrim: boolean; reserveSlotWidth: boolean; @@ -988,6 +970,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive?: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -997,7 +980,7 @@ function StatusWorkspaceActionSlot({ const kebab = useOpenKebabMenuVisibility(showKebab); return ( - + ({ }, statusWorkspaceListContainer: {}, statusGroupRow: { - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -1100,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupLeadingVisualSlot: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -1115,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -1124,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index 66df32bdbb..e1543fac41 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -4,6 +4,7 @@ import { type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Archive, + Circle, CircleCheck, Copy, MoreVertical, @@ -52,6 +53,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedCircle = withUnistyles(Circle); const ThemedPencil = withUnistyles(Pencil); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedPin = withUnistyles(Pin); @@ -63,6 +65,7 @@ const renameLeadingIcon = ); +const markAsUnreadLeadingIcon = ; const archiveLeadingIcon = ; const pinLeadingIcon = ; const unpinLeadingIcon = ; @@ -85,6 +88,7 @@ export interface SidebarWorkspaceMenuProps { onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -132,6 +136,7 @@ function SidebarWorkspaceMenuItems({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -193,6 +198,16 @@ function SidebarWorkspaceMenuItems({ Mark as read ) : null} + {onMarkAsUnread ? ( + + Mark as unread + + ) : null} {onTogglePin ? ( ( () => ({ @@ -399,6 +418,7 @@ export function SidebarWorkspaceContextMenu({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 2eeb6b043d..33f14afcb5 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -16,6 +16,7 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -27,19 +28,16 @@ import { import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; +import { DensityIcon } from "@/components/ui/density-icon"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ color: theme.colors.surface0, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedMonitor = withUnistyles(Monitor); -const ThemedFolder = withUnistyles(Folder); -const ThemedFolderGit2 = withUnistyles(FolderGit2); export function SidebarWorkspaceRowFrame({ workspace, @@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({ ) : null; } - let KindIcon: typeof ThemedMonitor; - if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; - else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; - else KindIcon = ThemedFolder; + let KindIcon: typeof Monitor; + if (workspaceKind === "local_checkout") KindIcon = Monitor; + else if (workspaceKind === "worktree") KindIcon = FolderGit2; + else KindIcon = Folder; const dotColorStyle = getStatusDotColorStyle(bucket); return ( - + {dotColorStyle ? : null} ); @@ -343,6 +341,8 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { ); } +export type SidebarWorkspaceTrailingPresentation = "visible" | "hidden" | "absent"; + /** * What the trailing slot shows for a row. Derived in one place because three row renderers * share it: the two project-mode rows and the status-mode row. The rule used to be copied @@ -368,7 +368,7 @@ export function resolveTrailingActionVisibility({ isTouchPlatform: boolean; showShortcut: boolean; }): { - showTrailing: boolean; + trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; showScrim: boolean; renderSlot: boolean; @@ -376,9 +376,13 @@ export function resolveTrailingActionVisibility({ } { const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing }); const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut; - const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab); + // Touch permanently replaces the stats with the menu. Only temporary shortcut hints + // conceal content while retaining its width, so desktop rows do not shift. + const hasContent = hasTrailing && !(hasArchiveAction && isTouchPlatform); + let trailingPresentation: SidebarWorkspaceTrailingPresentation = "absent"; + if (hasContent) trailingPresentation = showShortcut ? "hidden" : "visible"; return { - showTrailing, + trailingPresentation, showKebab, // The scrim paints the row's own hover background, so it can only be drawn on a hovered // row — over an unhovered one the gradient fades to the wrong color. That is also why @@ -389,7 +393,7 @@ export function resolveTrailingActionVisibility({ // does; the kebab only does on touch, where there is no hover for it to appear on and so // no scrim to let it overlay the title. Everywhere else the width goes back to the title // and the kebab fades in over its tail. - reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform), + reserveSlotWidth: hasContent || (hasArchiveAction && isTouchPlatform), }; } @@ -414,14 +418,18 @@ export function SidebarWorkspaceTrailingActionSlot({ } export function SidebarWorkspaceTrailingActionBase({ - visible, + presentation, children, }: { - visible: boolean; + presentation: SidebarWorkspaceTrailingPresentation; children: ReactNode; }) { - if (!children) return null; - return {children}; + if (presentation === "absent") return null; + return ( + + {children} + + ); } export function SidebarWorkspaceTrailingActionOverlay({ @@ -472,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceStatusDot: { position: "relative", - width: theme.iconSize.md, - height: 20, + width: densityIconBox("md"), + height: density.rowLineHeight, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -505,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({ // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", - lineHeight: 20, + lineHeight: density.rowLineHeight, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 63355ff7f9..c5f8a278bd 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -2,23 +2,21 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, type GestureResponderEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { useMutation } from "@tanstack/react-query"; -import * as Clipboard from "expo-clipboard"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { density } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { WorkspaceRenameModal } from "@/components/workspace-rename-modal"; +import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions"; import { useToast } from "@/contexts/toast-context"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; @@ -108,42 +106,14 @@ export function SidebarWorkspaceRow({ archiveController.archive(); }, [archiveController, isArchiving]); + const clipboard = useWorkspaceClipboardActions(); const handleCopyPath = useCallback(() => { - let copyTargetDirectory: string; - try { - copyTargetDirectory = requireWorkspaceDirectory({ - workspaceId: workspace.workspaceId, - workspaceDirectory: workspace.workspaceDirectory, - }); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("sidebar.workspace.toasts.workspacePathUnavailable"), - ); - return; - } - void Clipboard.setStringAsync(copyTargetDirectory); - toast.copied(t("sidebar.workspace.toasts.pathCopied")); - }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); + clipboard.copyPath(workspace); + }, [clipboard, workspace]); const handleCopyBranchName = useCallback(() => { - if (!workspace.currentBranch) { - return; - } - void Clipboard.setStringAsync(workspace.currentBranch); - toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); - }, [t, toast, workspace.currentBranch]); - - const renameMutation = useMutation({ - mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); - if (!client) { - throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); - } - await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); - }, - }); + clipboard.copyBranchName(workspace); + }, [clipboard, workspace]); const handleOpenRename = useCallback(() => { setIsRenameOpen(true); @@ -153,23 +123,22 @@ export function SidebarWorkspaceRow({ setIsRenameOpen(false); }, []); - const handleSubmitRename = useCallback( - async (value: string) => { - await renameMutation.mutateAsync(value.trim()); - }, - [renameMutation], - ); - const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -204,16 +173,13 @@ export function SidebarWorkspaceRow({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> - @@ -240,6 +206,7 @@ interface WorkspaceRowBodyProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -263,6 +230,7 @@ function WorkspaceRowBody({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, }: WorkspaceRowBodyProps) { const isCompact = useIsCompactFormFactor(); @@ -335,6 +303,7 @@ function WorkspaceRowBody({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -382,6 +351,7 @@ function WorkspaceRowBody({ onCopyPath={onCopyPath} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} /> @@ -407,6 +377,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -425,6 +396,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -432,7 +404,7 @@ function WorkspaceRowTrailingActions({ const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -454,7 +426,7 @@ function WorkspaceRowTrailingActions({ ) : null} {renderSlot ? ( - + ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts index 2068dbe271..465caf1344 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import { resolveSidebarWorkspaceAccessibilityLabel, @@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" }, workspaceTitleSource: "title", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Investigate search, Build host"); @@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { hostBadgeLabel: "Build host", pullRequestLabel: "Pull request 42", serviceLabel: "Service web running", + t: i18n.t, }); expect(label).toBe( @@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspaceTitleSource: "title", leadingProjectName: "Search project", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Search project, Investigate search, Build host"); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.ts index 955c9d2636..da3782f082 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts @@ -1,6 +1,7 @@ +import type { TFunction } from "i18next"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { WorkspaceTitleSource } from "@/hooks/use-settings"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; export function resolveSidebarWorkspacePrimaryLabel(input: { workspace: Pick; @@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { hostBadgeLabel?: string | null; pullRequestLabel?: string | null; serviceLabel?: string | null; + t: TFunction; }): string { return [ input.leadingProjectName, @@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { input.serviceLabel, input.workspace.statusBucket === "done" ? null - : STATUS_BUCKET_LABELS[input.workspace.statusBucket], + : getStatusBucketLabel(input.workspace.statusBucket, input.t), ] .filter((label): label is string => Boolean(label)) .join(", "); diff --git a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx index f26f562be4..d539fcf064 100644 --- a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx +++ b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx @@ -2,15 +2,7 @@ import { Fragment, useCallback, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type GestureResponderEvent } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { - ExternalLink, - Folder, - GitBranch, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - Globe, -} from "lucide-react-native"; +import { ExternalLink, Folder, GitBranch, Globe } from "lucide-react-native"; import { workspaceLabelKey, type WorkspaceLabelDefinition, @@ -23,6 +15,7 @@ import { getForgePresentation, normalizeForge } from "@/git/forge"; import { openExternalUrl } from "@/utils/open-external-url"; import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model"; import type { Theme } from "@/styles/theme"; +import { PullRequestStateIcon } from "@/git/pull-request-state-icon"; import { CheckIndicator } from "./check-indicator"; import type { CheckSummary, CheckSummaryState } from "./check-summary"; import { selectMetaRowItems, type MetaRowItem } from "./meta-items"; @@ -44,9 +37,6 @@ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedFolder = withUnistyles(Folder); const ThemedGitBranch = withUnistyles(GitBranch); -const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedGitMerge = withUnistyles(GitMerge); -const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed); const ThemedGlobe = withUnistyles(Globe); /** Stable identity so a row without labels doesn't re-select its items on every render. */ @@ -54,7 +44,6 @@ const EMPTY_LABELS: readonly WorkspaceLabelDefinition[] = []; const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged }); const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); /** @@ -212,7 +201,6 @@ function PullRequestItem({ hint }: { hint: PrHint }) { const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); - const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state]; return ( - + {isHovered ? ( + + ) : ( + + )} {hint.number} {/* An open change request is the unremarkable case and says nothing extra; a merged @@ -311,20 +300,6 @@ function ServiceItem({ summary }: { summary: WorkspaceServiceSummary }) { const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess }); -const PR_ICONS = { - open: ThemedGitPullRequest, - merged: ThemedGitMerge, - closed: ThemedGitPullRequestClosed, -} as const; - -// Same three colours the hover card gives the same three states — a change request has one -// identity, and it shouldn't shift when you hover the row that names it. -const PR_COLOR_MAPPINGS = { - open: successMapping, - merged: mergedMapping, - closed: dangerMapping, -} as const; - const PR_STATE_LABEL_KEYS = { merged: "workspace.git.pr.states.merged", closed: "workspace.git.pr.states.closed", diff --git a/packages/app/src/components/sidebar/workspace-trailing/index.tsx b/packages/app/src/components/sidebar/workspace-trailing/index.tsx index ed573b2bfb..1f7b91dd51 100644 --- a/packages/app/src/components/sidebar/workspace-trailing/index.tsx +++ b/packages/app/src/components/sidebar/workspace-trailing/index.tsx @@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useAppSettings } from "@/hooks/use-settings"; import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings"; -import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; +import { useCompactTimeAgo } from "@/hooks/use-time-ago"; export type { SidebarWorkspaceTrailing }; diff --git a/packages/app/src/components/split-container-focus.test.ts b/packages/app/src/components/split-container-focus.test.ts index 1880f924f4..69c4d6e639 100644 --- a/packages/app/src/components/split-container-focus.test.ts +++ b/packages/app/src/components/split-container-focus.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; -import { resolveSplitContainerRoot } from "@/components/split-container-focus"; +import { + hasMultipleVisiblePanes, + resolveSplitContainerRoot, + splitNodeContainsPane, +} from "@/components/split-container-focus"; import type { SplitNode } from "@/stores/workspace-layout-store"; const pane = (id: string): SplitNode => ({ kind: "pane", pane: { id, tabIds: [], focusedTabId: null }, }); +const hiddenPane = (id: string): SplitNode => ({ + kind: "pane", + pane: { id, tabIds: [], focusedTabId: null, hidden: true }, +}); const root: SplitNode = { kind: "group", group: { @@ -35,25 +43,22 @@ describe("split focus root", () => { ).toEqual({ root, usesFallbackStrip: false }); }); - it("renders only a maximized pane without entering focus mode", () => { + it("finds the maximized pane without replacing the rendered split tree", () => { + expect(splitNodeContainsPane(root, "right")).toBe(true); + expect(splitNodeContainsPane(root, "missing")).toBe(false); expect( - resolveSplitContainerRoot({ - root, - focusedPaneId: "left", - focusModeEnabled: false, - maximizedPaneId: "right", - }), - ).toEqual({ root: pane("right"), usesFallbackStrip: false }); - }); - - it("keeps normal splits when the maximized pane is unavailable", () => { - expect( - resolveSplitContainerRoot({ - root, - focusedPaneId: "left", - focusModeEnabled: false, - maximizedPaneId: "missing", - }), + resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }), ).toEqual({ root, usesFallbackStrip: false }); }); + + it("offers pane maximize only when another pane is visible", () => { + expect(hasMultipleVisiblePanes(root)).toBe(true); + expect(hasMultipleVisiblePanes(pane("left"))).toBe(false); + expect( + hasMultipleVisiblePanes({ + ...root, + group: { ...root.group, children: [pane("left"), hiddenPane("right")] }, + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/components/split-container-focus.ts b/packages/app/src/components/split-container-focus.ts index aa7ef6db3a..d0824d2ddd 100644 --- a/packages/app/src/components/split-container-focus.ts +++ b/packages/app/src/components/split-container-focus.ts @@ -4,9 +4,8 @@ export function resolveSplitContainerRoot(input: { root: SplitNode; focusedPaneId: string | null; focusModeEnabled: boolean | undefined; - maximizedPaneId?: string | null; }): { root: SplitNode; usesFallbackStrip: boolean } { - const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : input.maximizedPaneId; + const isolatedPaneId = input.focusModeEnabled ? input.focusedPaneId : null; if (!isolatedPaneId) return { root: input.root, usesFallbackStrip: false }; const isolatedPane = findPane(input.root, isolatedPaneId); if (!isolatedPane || isolatedPane.hidden === true) { @@ -15,6 +14,29 @@ export function resolveSplitContainerRoot(input: { return { root: { kind: "pane", pane: isolatedPane }, usesFallbackStrip: false }; } +/** Whether a split subtree contains the pane currently projected full-size. */ +export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean { + if (node.kind === "pane") return node.pane.id === paneId; + return node.group.children.some((child) => splitNodeContainsPane(child, paneId)); +} + +/** Whether a workspace pane has another visible pane it can be maximized over. */ +export function hasMultipleVisiblePanes(node: SplitNode): boolean { + let visiblePaneCount = 0; + const visit = (current: SplitNode): void => { + if (current.kind === "pane") { + if (current.pane.hidden !== true) visiblePaneCount += 1; + return; + } + for (const child of current.group.children) { + visit(child); + if (visiblePaneCount > 1) return; + } + }; + visit(node); + return visiblePaneCount > 1; +} + function findPane(node: SplitNode, paneId: string): SplitPane | null { if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null; for (const child of node.group.children) { diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 2f0c275321..9705f3be15 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,6 +1,5 @@ import { Fragment, - memo, useCallback, useEffect, useMemo, @@ -27,7 +26,7 @@ import { type DragStartEvent, } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; -import { View, Text } from "react-native"; +import { View, Text, type LayoutChangeEvent } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, @@ -36,10 +35,19 @@ import Animated, { import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; +import { + resolveExplorerSidebarDockSizes, + resolveExplorerSidebarWidth, +} from "@/components/explorer-sidebar-layout"; import { RetainedPanel } from "@/components/retained-panel"; -import { resolveSplitContainerRoot } from "@/components/split-container-focus"; +import { + hasMultipleVisiblePanes, + resolveSplitContainerRoot, + splitNodeContainsPane, +} from "@/components/split-container-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { + removeWindowChromeCorner, WindowChromeRegion, WindowChromeSafeArea, useWindowChromeCorners, @@ -59,22 +67,20 @@ import { deriveWorkspacePaneState, getWorkspacePaneDescriptors, } from "@/screens/workspace/workspace-pane-state"; -import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; -import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes"; -import { - WorkspacePaneContent, - type WorkspacePaneContentModel, -} from "@/screens/workspace/workspace-pane-content"; +import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; +import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, } from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { + createDefaultLayout, findPaneById, useWorkspaceLayoutStore, type SplitNode, @@ -83,13 +89,13 @@ import { } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { RenderProfile } from "@/utils/render-profiler"; -import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { isNative } from "@/constants/platform"; -import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; interface SplitContainerProps { layout: WorkspaceLayout; + renderMainHeader?: () => ReactNode; + renderExplorerSidebarHeaderAction?: () => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -125,6 +131,7 @@ interface SplitContainerProps { position: "left" | "right" | "top" | "bottom"; }) => void; onMoveTabToPane: (tabId: string, toPaneId: string) => void; + onSelectTabInPane: (paneId: string, tabId: string) => void; onResizeSplit: (groupId: string, sizes: number[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; focusModeEnabled?: boolean; @@ -144,6 +151,7 @@ interface SplitPaneDropData { const EMPTY_SPLIT_NODES: SplitNode[] = []; const EMPTY_SPLIT_SIZES: number[] = []; +const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar"; function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; @@ -165,18 +173,21 @@ function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData return undefined; } -interface SplitNodeViewProps extends Omit { +interface SplitNodeViewProps extends Omit< + SplitContainerProps, + "layout" | "onMoveTabToPane" | "onSelectTabInPane" +> { node: SplitNode; uiTabs: WorkspaceTab[]; focusedPaneId: string | null; - sidePanelPaneId: string | null; - maximizedPaneId: string | null; - onTogglePaneMaximized: (paneId: string) => void; activeDragTabId: string | null; showDropZones: boolean; dropPreview: SplitDropZoneHover | null; tabDropPreview: TabDropPreview | null; windowChromeCorners: WindowChromeCorners; + maximizedPaneId: string | null; + workspaceHasMultiplePanes: boolean; + onTogglePaneMaximized: (paneId: string) => void; } interface SplitPaneViewProps extends Omit< @@ -199,82 +210,6 @@ interface SplitPaneViewProps extends Omit< tabDropPreview: TabDropPreview | null; } -interface MountedTabSlotProps { - tabDescriptor: WorkspaceTabDescriptor; - isVisible: boolean; - isWorkspaceFocused: boolean; - isPaneFocused: boolean; - paneId: string; - onFocusPane: (paneId: string) => void; - buildPaneContentModel: (input: { - paneId: string; - tab: WorkspaceTabDescriptor; - }) => WorkspacePaneContentModel; -} - -const MountedTabSlot = memo(function MountedTabSlot({ - tabDescriptor, - isVisible, - isWorkspaceFocused, - isPaneFocused, - paneId, - onFocusPane, - buildPaneContentModel, -}: MountedTabSlotProps) { - const content = useMemo( - () => - buildPaneContentModel({ - paneId, - tab: tabDescriptor, - }), - [buildPaneContentModel, paneId, tabDescriptor], - ); - - const handleFocusPane = useCallback(() => { - onFocusPane(paneId); - }, [onFocusPane, paneId]); - - return ( - - - - - - ); -}); - -function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { - const cacheRef = useRef(new Map()); - const tabDescriptorMap = useMemo(() => { - const next = new Map(); - for (const tabDescriptor of tabDescriptors) { - const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId); - if ( - cachedDescriptor && - cachedDescriptor.key === tabDescriptor.key && - cachedDescriptor.kind === tabDescriptor.kind && - cachedDescriptor.state === tabDescriptor.state && - workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target) - ) { - next.set(tabDescriptor.tabId, cachedDescriptor); - continue; - } - next.set(tabDescriptor.tabId, tabDescriptor); - } - return next; - }, [tabDescriptors]); - useEffect(() => { - cacheRef.current = tabDescriptorMap; - }, [tabDescriptorMap]); - - return tabDescriptorMap; -} - interface DragMoveRects { translatedRect: { left: number; top: number; width: number; height: number }; overRect: { left: number; top: number; width: number; height: number }; @@ -372,6 +307,8 @@ const dropCollisionDetection: CollisionDetection = (args) => { export function SplitContainer({ layout, + renderMainHeader, + renderExplorerSidebarHeaderAction, workspaceKey, normalizedServerId, normalizedWorkspaceId, @@ -397,6 +334,7 @@ export function SplitContainer({ onSplitPane, onSplitPaneEmpty, onMoveTabToPane, + onSelectTabInPane, onResizeSplit, onReorderTabsInPane, focusModeEnabled, @@ -413,56 +351,10 @@ export function SplitContainer({ } | null>(null); const maximizedPaneId = maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; - const sidePanelPaneId = useWorkspaceLayoutStore( - (state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null, + const explorerSidebarPaneId = useWorkspaceLayoutStore( + (state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, ); - useEffect(() => { - if (!maximizedPaneId) { - return; - } - const isolatedPane = findPaneById(layout.root, maximizedPaneId); - if (focusModeEnabled || !isolatedPane || isolatedPane.hidden === true) { - setMaximizedPane(null); - } - }, [focusModeEnabled, layout.root, maximizedPaneId]); - - const handleTogglePaneMaximized = useCallback( - (paneId: string) => { - setMaximizedPane((currentPane) => - currentPane?.workspaceKey === workspaceKey && currentPane.paneId === paneId - ? null - : { workspaceKey, paneId }, - ); - }, - [workspaceKey], - ); - - const handleExplorerMaximizeAction = useCallback( - (action: KeyboardActionDefinition): boolean => { - if (action.id !== "workspace.explorer.maximize.toggle") { - return false; - } - const explorerPane = findPaneById(layout.root, sidePanelPaneId); - if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) { - return true; - } - onFocusPane(explorerPane.id); - handleTogglePaneMaximized(explorerPane.id); - return true; - }, - [sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane], - ); - - useKeyboardActionHandler({ - handlerId: `workspace-explorer-maximize:${workspaceKey}`, - actions: ["workspace.explorer.maximize.toggle"] as const, - enabled: isWorkspaceFocused, - priority: 100, - isActive: () => true, - handle: handleExplorerMaximizeAction, - }); - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -475,15 +367,110 @@ export function SplitContainer({ ); const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); + const explorerSidebarPane = useMemo( + () => findPaneById(layout.root, explorerSidebarPaneId), + [layout.root, explorerSidebarPaneId], + ); + const mainRoot = useMemo( + () => removePaneFromSplitTree(layout.root, explorerSidebarPaneId), + [layout.root, explorerSidebarPaneId], + ); + const workspaceHasMultiplePanes = Boolean(mainRoot && hasMultipleVisiblePanes(mainRoot)); + useEffect(() => { + if ( + maximizedPaneId && + (focusModeEnabled || + !workspaceHasMultiplePanes || + !mainRoot || + !splitNodeContainsPane(mainRoot, maximizedPaneId)) + ) { + setMaximizedPane(null); + } + }, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasMultiplePanes]); + const handleTogglePaneMaximized = useCallback( + (paneId: string) => { + setMaximizedPane((current) => + current?.workspaceKey === workspaceKey && current.paneId === paneId + ? null + : { workspaceKey, paneId }, + ); + }, + [workspaceKey], + ); const splitRoot = useMemo( () => resolveSplitContainerRoot({ - root: layout.root, + root: mainRoot ?? createDefaultLayout().root, focusedPaneId: layout.focusedPaneId, focusModeEnabled, - maximizedPaneId, }), - [focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId], + [focusModeEnabled, layout.focusedPaneId, mainRoot], + ); + const storedExplorerSidebarWidth = useWorkspaceLayoutStore( + (state) => state.explorerSidebarWidthByWorkspace[workspaceKey], + ); + const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); + const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0); + const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState( + null, + ); + const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth; + const explorerSidebarWidth = resolveExplorerSidebarWidth({ + requestedWidth: requestedExplorerSidebarWidth, + containerWidth: workspaceShellWidth, + }); + const explorerSidebarDockSizes = useMemo( + () => + resolveExplorerSidebarDockSizes({ + requestedWidth: requestedExplorerSidebarWidth, + containerWidth: workspaceShellWidth, + }), + [requestedExplorerSidebarWidth, workspaceShellWidth], + ); + const renderExplorerSidebarDock = Boolean( + !focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true, + ); + const mainColumnWindowChromeCorners = renderExplorerSidebarDock + ? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right") + : inheritedWindowChromeCorners; + const mainColumnStyle = styles.mainColumn; + const explorerSidebarDockStyle = useMemo( + () => [styles.explorerSidebarDock, { width: explorerSidebarWidth }], + [explorerSidebarWidth], + ); + const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => { + const nextWidth = event.nativeEvent.layout.width; + setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth)); + }, []); + const previewExplorerSidebarResize = useCallback( + (_groupId: string, sizes: number[]) => { + const nextRatio = sizes[1]; + if (nextRatio !== undefined) { + setPreviewExplorerSidebarWidth( + resolveExplorerSidebarWidth({ + requestedWidth: nextRatio * workspaceShellWidth, + containerWidth: workspaceShellWidth, + }), + ); + } + }, + [workspaceShellWidth], + ); + const commitExplorerSidebarResize = useCallback( + (_groupId: string, sizes: number[]) => { + setPreviewExplorerSidebarWidth(null); + const nextRatio = sizes[1]; + if (nextRatio !== undefined) { + resizeExplorerSidebar( + workspaceKey, + resolveExplorerSidebarWidth({ + requestedWidth: nextRatio * workspaceShellWidth, + containerWidth: workspaceShellWidth, + }), + ); + } + }, + [resizeExplorerSidebar, workspaceKey, workspaceShellWidth], ); const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); const handleDragStart = useCallback((event: DragStartEvent) => { @@ -521,6 +508,21 @@ export function SplitContainer({ return; } + const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null; + const destinationPaneId = + overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop" + ? overData.paneId + : null; + const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main"; + if ( + !activeTab || + !panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost) + ) { + setDropPreview(null); + setTabDropPreview(null); + return; + } + if (overData?.kind === "workspace-tab") { const preview = computeTabOverDropPreview({ activeData, @@ -542,7 +544,7 @@ export function SplitContainer({ setDropPreview(computePaneOverDropPreview({ overData, rects })); }, - [panesById, uiTabs], + [normalizedServerId, panesById, explorerSidebarPaneId, uiTabs], ); const applyTabDropEnd = useCallback( @@ -639,47 +641,101 @@ export function SplitContainer({ onDragCancel={handleDragCancel} onDragEnd={handleDragEnd} > - {splitRoot.usesFallbackStrip && } - + + + + {renderMainHeader?.()} + {splitRoot.usesFallbackStrip && } + {renderRoot ? ( + + ) : null} + + + {renderExplorerSidebarDock && explorerSidebarPane ? ( + <> + + + + + + ) : null} + {activeDragTabId ? (