diff --git a/.agents/skills/release-beta/SKILL.md b/.agents/skills/release-beta/SKILL.md index fa8b7292806..ec8b7cd971f 100644 --- a/.agents/skills/release-beta/SKILL.md +++ b/.agents/skills/release-beta/SKILL.md @@ -1,13 +1,9 @@ --- name: release-beta -description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag. +description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". user-invocable: true --- # Release beta -Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc. - -During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously. - -Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag. +Follow the **Beta flow** and **Beta release** completion checklist in `docs/release.md` end-to-end. diff --git a/.agents/skills/release-stable/SKILL.md b/.agents/skills/release-stable/SKILL.md index 9755e33e3ea..df33b07d0b1 100644 --- a/.agents/skills/release-stable/SKILL.md +++ b/.agents/skills/release-stable/SKILL.md @@ -6,8 +6,4 @@ user-invocable: true # Release stable -Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc. - -For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously. - -The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps. +Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end. diff --git a/.claude/skills/release-beta b/.claude/skills/release-beta index 0c8d5971d0d..859408e4e25 120000 --- a/.claude/skills/release-beta +++ b/.claude/skills/release-beta @@ -1 +1 @@ -../../.agents/skills/release-beta \ No newline at end of file +unsupported reparse tag 0xa000000c \ No newline at end of file diff --git a/.claude/skills/release-stable b/.claude/skills/release-stable index a174e9dd57f..859408e4e25 120000 --- a/.claude/skills/release-stable +++ b/.claude/skills/release-stable @@ -1 +1 @@ -../../.agents/skills/release-stable \ No newline at end of file +unsupported reparse tag 0xa000000c \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 195651822cf..c793b23cfae 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me. + Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically. Before opening, please: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index de533faa4d0..f1092d7757a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Discord + - name: Product discussion on Discord url: https://discord.gg/jz8T2uahpH - about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat. + about: Product discussion, including feature requests + - name: Product discussion on GitHub + url: https://github.com/getpaseo/paseo/discussions + about: Product discussion, including feature requests diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml deleted file mode 100644 index cc08d17bcb0..00000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Feature request -description: Propose a new feature or a change to existing behavior. -title: "feat: " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain. - - Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code. - - - type: checkboxes - id: prior-search - attributes: - label: Prior search - options: - - label: I searched existing issues and discussions, and this isn't already proposed. - required: true - - - type: textarea - id: problem - attributes: - label: What's the problem - description: What are you actually trying to do, and why is the current behavior in the way? - placeholder: | - When I'm doing X, I want to Y, but Paseo currently Z. - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: What would solve it - description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives you considered - description: Optional. What else did you try, and why doesn't it work? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7d5039aed49..2fbe37e9189 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,27 +1,34 @@ ### Linked issue Closes # - - ### Type of change - [ ] Bug fix -- [ ] New feature (with prior issue + design alignment) -- [ ] Refactor / code improvement +- [ ] New feature +- [ ] Enhancement +- [ ] Refactor - [ ] Docs -### What does this PR do +### Reasoning + + + +### Goals - + -### How did you verify it +### Non-goals + + + +### QA ### Checklist -- [ ] One focused change. Unrelated cleanups split out. +- [ ] One focused change - [ ] `npm run typecheck` passes - [ ] `npm run lint` passes -- [ ] `npm run format` ran (Biome) -- [ ] UI changes include screenshots or video for every affected platform +- [ ] `npm run format` passes +- [ ] QA evidence - [ ] Tests added or updated where it made sense diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml new file mode 100644 index 00000000000..c156ec0a16b --- /dev/null +++ b/.github/ci-paths.yml @@ -0,0 +1,71 @@ +# Pull-request checks are routed by the behavior they prove. Package consumers +# do not inherit their dependencies' test suites; main and manual runs execute +# every contract regardless of these paths. +routing: + - ".github/ci-paths.yml" + +workspace: + - ".mise.toml" + - ".tool-versions" + - "package.json" + - "package-lock.json" + - "patches/**" + - "scripts/**" + - "tsconfig.json" + - "tsconfig.base.json" + - "vitest.config.ts" + +ci: + - ".github/actions/**" + - ".github/workflows/ci.yml" + +format: + - ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - "packages/expo-two-way-audio/**" + +quality: + - "**/*.{cjs,js,json,jsx,mjs,ts,tsx}" + - "packages/expo-two-way-audio/**" + +hub: + - "packages/cli/src/commands/hub/**" + - "packages/server/src/server/hub/**" + +server: + - "packages/server/**" + - "packages/app/e2e/support/fixtures/recording.*" + +desktop: + - "packages/desktop/**" + - "packages/app/src/desktop/**" + - "packages/server/src/server/browser-tools/**" + - "packages/app/e2e/support/**" + - "packages/app/*config.{cjs,js,ts}" + - "packages/app/package.json" + +app: + - "packages/app/**" + - "packages/expo-two-way-audio/**" + +sdk: + - "packages/client/**" + - "packages/highlight/**" + - "packages/protocol/**" + +browser: + - "packages/app/src/!(desktop)/**" + - "packages/app/e2e/browser/**" + - "packages/app/e2e/support/**" + - "packages/app/assets/**" + - "packages/app/public/**" + - "packages/app/index.ts" + - "packages/app/*config.{cjs,js,ts}" + - "packages/app/package.json" + +relay: + - "packages/relay/**" + +cli: + - "packages/cli/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bebab954a78..e7fa5c814cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,48 @@ env: ONNXRUNTIME_NODE_INSTALL: skip jobs: + changes: + name: changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }} + format: ${{ steps.filter.outputs.format }} + quality: ${{ steps.filter.outputs.quality }} + hub: ${{ steps.filter.outputs.hub }} + server: ${{ steps.filter.outputs.server }} + desktop: ${{ steps.filter.outputs.desktop }} + app: ${{ steps.filter.outputs.app }} + sdk: ${{ steps.filter.outputs.sdk }} + browser: ${{ steps.filter.outputs.browser }} + relay: ${{ steps.filter.outputs.relay }} + cli: ${{ steps.filter.outputs.cli }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Detect affected CI contracts + id: filter + uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + with: + filters: .github/ci-paths.yml + + - name: Validate CI contracts + run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs + format: + name: format + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest env: - # This job never executes Electron. Skipping the hosted binary avoids - # unrelated npm ci failures when Electron's CDN returns 504. ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -36,9 +73,12 @@ jobs: run: node scripts/npm-retry.mjs ci - name: Check formatting - run: npx oxfmt --check . + run: npm run format:check lint: + name: lint + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -63,6 +103,9 @@ jobs: run: npm run lint typecheck: + name: typecheck + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -76,6 +119,7 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build server stack run: npm run build:server @@ -88,16 +132,14 @@ jobs: npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server - server-tests: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - name: server-tests (${{ matrix.os }}) + server-tests-ubuntu: + name: server-tests (ubuntu-latest) + 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: + steps: &server_test_steps - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -108,45 +150,51 @@ jobs: cache: "npm" - name: Fetch origin/main (worktree tests) + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: git fetch --no-tags origin main:refs/remotes/origin/main - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Install agent CLIs for provider tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai - name: Build server dependencies run: npm run build:server-deps - name: Run server tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/server env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - desktop-tests: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + - 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 + + server-tests-windows: + name: server-tests (windows-latest) + 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 + + 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 timeout-minutes: 30 permissions: contents: read - pull-requests: read - steps: + steps: &desktop_test_steps - uses: actions/checkout@v4 - - name: Detect desktop changes - if: matrix.os == 'ubuntu-latest' - id: desktop_changes - uses: dorny/paths-filter@v3 - with: - filters: | - desktop: - - 'packages/desktop/**' - - uses: actions/setup-node@v4 with: node-version: "22" @@ -154,37 +202,42 @@ jobs: - name: Install dependencies with retry run: node scripts/npm-retry.mjs ci + - name: Build server stack run: npm run build:server - name: Run desktop tests run: npm run test --workspace=@getpaseo/desktop - - name: Build app dependencies for desktop E2E - if: matrix.os == 'ubuntu-latest' - run: npm run build:app-deps - - name: Install virtual display - if: matrix.os == 'ubuntu-latest' + if: ${{ runner.os == 'Linux' }} run: sudo apt-get update && sudo apt-get install -y xvfb xauth - - name: Run real Electron browser tab bridge E2E - if: matrix.os == 'ubuntu-latest' - run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop + - name: Install Playwright browser + if: ${{ runner.os == 'Linux' }} + run: npx playwright install chromium + + - name: Run desktop renderer E2E + if: ${{ runner.os == 'Linux' }} + run: npm run test:e2e:renderer --workspace=@getpaseo/desktop + + - name: Run desktop browser E2E + if: ${{ runner.os == 'Linux' }} + run: npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop env: - PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e + PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR: ${{ runner.temp }}/desktop-browser-e2e - - name: Upload browser tab bridge diagnostics + - name: Upload desktop browser diagnostics uses: actions/upload-artifact@v4 - if: failure() && matrix.os == 'ubuntu-latest' + if: ${{ failure() && runner.os == 'Linux' }} with: - name: browser-tab-bridge-e2e - path: ${{ runner.temp }}/browser-tab-bridge-e2e + name: desktop-browser-e2e + path: ${{ runner.temp }}/desktop-browser-e2e if-no-files-found: ignore retention-days: 7 - name: Build and smoke unpacked desktop app - if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' + if: ${{ runner.os == 'Linux' }} run: npm run build:desktop -- --publish never --linux --x64 --dir env: EP_GH_IGNORE_TIME: true @@ -192,15 +245,28 @@ jobs: PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke - name: Upload packaged smoke diagnostics - if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' 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 + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + steps: *desktop_test_steps + app-tests: + name: app-tests + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -214,6 +280,7 @@ jobs: - name: Install dependencies with retry run: node scripts/npm-retry.mjs ci + - name: Install Playwright browsers timeout-minutes: 10 run: npx playwright install chromium @@ -225,6 +292,9 @@ jobs: run: npm run test --workspace=@getpaseo/app sdk-tests: + name: sdk-tests + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -238,6 +308,7 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build client dependencies run: npm run build:client @@ -247,24 +318,22 @@ jobs: - name: Run client tests run: npm run test --workspace=@getpaseo/client + - name: Run highlight tests + run: npm run test --workspace=@getpaseo/highlight + - name: Typecheck client examples run: npm run typecheck:examples --workspace=@getpaseo/client - playwright: - strategy: - fail-fast: false - matrix: - include: - - { label: "shard 1/4", shard: 1, desktop: false } - - { label: "shard 2/4", shard: 2, desktop: false } - - { label: "shard 3/4", shard: 3, desktop: false } - - { label: "shard 4/4", shard: 4, desktop: false } - - { label: "desktop overlay", shard: "desktop", desktop: true } - name: playwright (${{ matrix.label }}) + playwright-1: + name: playwright (shard 1/4) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - steps: + PLAYWRIGHT_SHARD: "1/4" + PLAYWRIGHT_ARTIFACT: "1" + steps: &playwright_test_steps - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -274,6 +343,7 @@ jobs: - name: Install dependencies with retry run: node scripts/npm-retry.mjs ci + - name: Install Playwright browsers timeout-minutes: 10 run: npx playwright install chromium @@ -285,30 +355,60 @@ jobs: run: npm run build:server - name: Install agent CLIs for provider tests - if: ${{ !matrix.desktop }} run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run Playwright E2E tests - if: ${{ !matrix.desktop }} - run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4 + run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - - name: Run desktop-overlay Playwright tests - if: ${{ matrix.desktop }} - run: npm run test:e2e:desktop --workspace=@getpaseo/app - - name: Upload test artifacts uses: actions/upload-artifact@v4 if: failure() with: - name: playwright-results-${{ matrix.shard }} + name: playwright-results-${{ env.PLAYWRIGHT_ARTIFACT }} path: | packages/app/test-results/ packages/app/playwright-report/ retention-days: 7 + playwright-2: + name: playwright (shard 2/4) + needs: changes + 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 + + playwright-3: + name: playwright (shard 3/4) + needs: changes + 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 + + playwright-4: + name: playwright (shard 4/4) + needs: changes + 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 + relay-tests: + name: relay-tests + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -329,16 +429,19 @@ jobs: - name: Run relay tests run: npm run test --workspace=@getpaseo/relay - cli-tests: - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] + cli-tests-1: + name: cli-tests (shard 1/3) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest - name: cli-tests (shard ${{ matrix.shard }}/3) env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - steps: + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "1" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: &cli_test_steps - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -349,14 +452,39 @@ 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 - env: - PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" - PASEO_DICTATION_ENABLED: "0" - PASEO_VOICE_MODE_ENABLED: "0" - PASEO_CLI_TEST_SHARD: ${{ matrix.shard }} - PASEO_CLI_TEST_SHARD_TOTAL: "3" + + cli-tests-2: + name: cli-tests (shard 2/3) + needs: changes + 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" + PASEO_CLI_TEST_SHARD: "2" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: *cli_test_steps + + cli-tests-3: + name: cli-tests (shard 3/3) + needs: changes + 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" + PASEO_CLI_TEST_SHARD: "3" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: *cli_test_steps diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 4bf1ae8076a..6da83851875 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -104,3 +104,27 @@ jobs: - name: Build Nix desktop package run: nix build .#desktop -o result-desktop + + build-desktop-darwin: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Build Nix desktop package + run: nix build .#desktop -o result-desktop + + - name: Verify macOS application bundle + run: | + set -euo pipefail + + app="result-desktop/Applications/Paseo.app" + test -x "$app/Contents/MacOS/Paseo" + test -L result-desktop/bin/paseo-desktop + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = "sh.paseo.desktop" + find "$app" -name Info.plist -print0 | xargs -0 -n 1 plutil -lint diff --git a/.mise.toml b/.mise.toml index 7c1a0bad954..068718b615d 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,10 +1,10 @@ +[vars] +android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}' + [env] -ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0" +ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}" _.path = [ - "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin", - "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools", - "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator", ] - -[tools] -java = "21" diff --git a/.oxlintrc.json b/.oxlintrc.json index bdcab1dc5b3..5fe29499a5e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -130,7 +130,7 @@ ] } }, - // Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban. + // Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban. { "files": [ "packages/app/src/assistant-file-links/use-file-link.ts", @@ -154,10 +154,9 @@ "packages/app/src/hooks/use-is-local-daemon.ts", "packages/app/src/hooks/use-keyboard-shortcut-overrides.ts", "packages/app/src/hooks/use-preferred-editor.ts", - "packages/app/src/hooks/use-project-icon-query.ts", "packages/app/src/hooks/use-settings/index.ts", "packages/app/src/panels/terminal-panel.tsx", - "packages/app/src/projects/project-icons.ts", + "packages/app/src/projects/icons.ts", "packages/app/src/provider-usage/use-provider-usage.ts", "packages/app/src/screens/project-settings-screen.tsx", "packages/app/src/screens/workspace/use-workspace-checkout-status.ts", @@ -189,9 +188,9 @@ "packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/attachment-lightbox.tsx", - "packages/app/src/components/browser-pane.electron.tsx", - "packages/app/src/components/browser-pane.tsx", - "packages/app/src/components/browser-pane.web.tsx", + "packages/app/src/desktop/browser/pane/index.electron.tsx", + "packages/app/src/desktop/browser/pane/index.tsx", + "packages/app/src/desktop/browser/pane/index.web.tsx", "packages/app/src/components/command-center.tsx", "packages/app/src/components/context-window-meter.tsx", "packages/app/src/components/dictation-controls.tsx", @@ -280,7 +279,7 @@ } }, { - "files": ["**/e2e/fixtures.ts"], + "files": ["**/e2e/**/fixtures.ts"], "rules": { "no-empty-pattern": "off" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd2895dc51..efe337f7a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,270 @@ # Changelog -## 0.2.0-beta.1 - 2026-07-17 +## 0.4.0 - 2026-08-13 + +### Breaking + +- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053)) + +### Added + +- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208)) +- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059)) +- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs)) +- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon)) +- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227)) +- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215)) +- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141)) +- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243)) +- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik)) + +### Improved + +- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240)) +- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter)) +- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214)) +- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323)) +- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322)) + +### Fixed + +- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263)) +- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107)) +- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241)) +- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234)) +- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159)) +- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224)) +- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233)) +- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311)) +- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176)) +- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456)) +- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123)) +- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281)) +- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy)) +- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj)) +- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish)) +- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289)) +- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279)) +- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331)) +- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315)) +- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai)) + +## 0.3.1 - 2026-08-09 + +### Added + +- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter)) +- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf)) + +### Improved + +- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter)) +- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078)) +- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter)) +- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### Fixed + +- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071)) +- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076)) +- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055)) +- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051)) +- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050)) +- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi)) +- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp)) +- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd)) +- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter)) +- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon)) +- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043)) + +## 0.3.0 - 2026-08-08 + +### Added + +- New mobile terminal with text selection, copy, paste, and faster rendering ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830)) +- Redesigned sidebar with clearer status colours and rows that are quicker to scan ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711)) + - Choose what a sidebar workspace row shows: host, pull request, checks, and scripts + - Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790)) +- Search your history by workspace, agent, and branch ([#2995](https://github.com/getpaseo/paseo/pull/2995)) +- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos)) +- Run git and workspace actions from the Command Center ([#2749](https://github.com/getpaseo/paseo/pull/2749)) +- Change model, reasoning, mode, plan, and fast from the Command Center ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu)) +- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi)) +- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941)) +- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792)) +- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch)) +- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933)) +- Choose which orchestration skills Paseo installs ([#2680](https://github.com/getpaseo/paseo/pull/2680)) +- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752)) +- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922)) +- Pick a local branch or its origin counterpart as the base for a new worktree ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger)) +- Paste images from the clipboard on mobile ([#2793](https://github.com/getpaseo/paseo/pull/2793)) + +### Improved + +- Provider data is cached in the client for a faster startup +- Recent chats stay live in the background, so switching back shows current messages right away ([#2842](https://github.com/getpaseo/paseo/pull/2842)) +- Coming back to a workspace from Settings is faster ([#2791](https://github.com/getpaseo/paseo/pull/2791)) +- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979)) +- Paseo runs fewer git processes on busy machines ([#2797](https://github.com/getpaseo/paseo/pull/2797)) +- Copying part of an assistant response keeps its lists, links, and formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter)) +- See the full conversation from a native Claude subagent ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760)) +- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco)) +- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891)) +- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912)) +- OMP context usage updates while a turn is running ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava)) +- OpenCode reports background activity from its own busy status ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn)) +- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907)) +- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848)) +- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755)) +- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706)) +- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi)) +- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902)) +- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931)) +- The file viewer shows one accurate status when a file changes or is deleted ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694)) +- The Nix desktop package is smaller ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8)) +- The Nix desktop app uses the right icon on Linux and macOS ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Paseo Desktop builds from the Nix flake on macOS ([#2556](https://github.com/getpaseo/paseo/pull/2556) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### Fixed + +- Pi 0.84 agents no longer crash-loop on every prompt ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa)) +- Workspace file watching no longer stalls the daemon ([#2858](https://github.com/getpaseo/paseo/pull/2858)) +- Messages no longer duplicate or arrive out of order after a reconnect or resume ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718)) +- Git status and diffs pick up changes in nested folders on every desktop OS ([#2775](https://github.com/getpaseo/paseo/pull/2775)) +- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter)) +- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter)) +- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757)) +- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662)) +- ACP permission prompts no longer disappear mid-turn ([#2762](https://github.com/getpaseo/paseo/pull/2762)) +- A workspace no longer looks idle while its native subagents run ([#2777](https://github.com/getpaseo/paseo/pull/2777)) +- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942)) +- Windows terminals handle deferred startup failures without crashing +- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987)) +- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714)) +- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838)) +- Images stay anchored while a conversation reloads +- Swipe and drag gestures work in the mobile sidebar ([#2709](https://github.com/getpaseo/paseo/pull/2709)) +- Dictated prompts no longer disappear when you submit them ([#2745](https://github.com/getpaseo/paseo/pull/2745)) +- The composer stays above the keyboard on Android +- Typing with an IME on mobile no longer loses composed text +- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937)) +- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700)) +- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905)) +- The pull request panel updates after you switch branches ([#2699](https://github.com/getpaseo/paseo/pull/2699)) +- New Workspace shows its isolation controls right away ([#2702](https://github.com/getpaseo/paseo/pull/2702)) +- Sidebar shortcuts leave focus mode ([#2717](https://github.com/getpaseo/paseo/pull/2717)) +- Agents started by the NixOS service no longer run in production mode ([#2697](https://github.com/getpaseo/paseo/pull/2697) by [@shin-sakata](https://github.com/shin-sakata)) + +## 0.2.5 - 2026-07-30 + +### Fixed + +- Fixed the Linux Debian package ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc)) + +## 0.2.4 - 2026-07-30 + +### Added + +- Set an agent's thinking level from the CLI ([#2533](https://github.com/getpaseo/paseo/pull/2533)) +- Switch projects while creating a workspace with ⌘P / Ctrl+P ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev)) +- Open a project or workspace folder from the sidebar ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030)) +- Flick up on the chat to dismiss the keyboard ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx)) +- Custom providers can read the agent's working directory ([#2563](https://github.com/getpaseo/paseo/pull/2563)) + +### Fixed + +- The same repository added on more than one machine appears as one project in the sidebar ([#2565](https://github.com/getpaseo/paseo/pull/2565)) +- Idle agents keep their background work instead of shutting down when unused ([#2590](https://github.com/getpaseo/paseo/pull/2590)) +- Desktop no longer crashes at startup while restoring your file tree ([#2595](https://github.com/getpaseo/paseo/pull/2595)) +- Plan approval only shows the latest proposal ([#2534](https://github.com/getpaseo/paseo/pull/2534)) +- Opus 5 appears once in the model list, with its full 1M context ([#2497](https://github.com/getpaseo/paseo/pull/2497)) +- The context meter no longer blanks out partway through a conversation ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava)) +- A very large working diff no longer drops your session ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs)) +- Interrupting Pi no longer surfaces a stream error ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger)) +- Grok usage shows again in Settings ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd)) +- OMP models that report no context window now load ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky)) +- Paseo's own tools are available directly in OMP ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd)) +- Codex finds the skills defined in your project ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang)) +- Pull request comments containing HTML render correctly ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger)) +- Self-hosted forge links keep their port ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991)) +- The Linux AppImage launches when opened from your desktop ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray)) +- Repository search works with older versions of the GitHub CLI ([#2611](https://github.com/getpaseo/paseo/pull/2611)) + +## 0.2.3 - 2026-07-27 + +### Added + +- Manage workspace scripts from the CLI and agent MCP tools ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger)) +- Copy terminal IDs from terminal tab menus ([#2371](https://github.com/getpaseo/paseo/pull/2371)) +- Long Markdown lines wrap by default in the file editor ([#2459](https://github.com/getpaseo/paseo/pull/2459)) + +### Improved + +- Desktop stops its managed daemon when you quit unless “Keep daemon running after quit” is enabled ([#2454](https://github.com/getpaseo/paseo/pull/2454)) +- Remote terminal and file traffic uses less bandwidth over encrypted connections ([#2480](https://github.com/getpaseo/paseo/pull/2480)) +- Workspace search now shows and matches project names ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter)) +- Claude usage shows model-specific weekly limits ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter)) +- OMP models show only the thinking levels they support ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid)) + +### Fixed + +- Image uploads preserve the correct image format ([#2380](https://github.com/getpaseo/paseo/pull/2380)) +- Large file views no longer disconnect the session ([#2482](https://github.com/getpaseo/paseo/pull/2482)) +- Reaching the top of a chat loads the complete older history ([#2481](https://github.com/getpaseo/paseo/pull/2481)) +- Parent agents stay available while child agents are working ([#2458](https://github.com/getpaseo/paseo/pull/2458)) +- Stale client connections no longer exhaust daemon memory ([#2169](https://github.com/getpaseo/paseo/pull/2169)) +- Pin and unpin shortcuts work when sidebar sections are collapsed ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter)) +- `Shift+Tab` no longer changes a background agent’s permission mode ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter)) +- Proxied services preserve ports in redirects ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter)) +- Provider settings open correctly above the model selector ([#2476](https://github.com/getpaseo/paseo/pull/2476)) +- Clicking the file editor correctly focuses its pane ([#2457](https://github.com/getpaseo/paseo/pull/2457)) + +## 0.2.2 - 2026-07-25 + +### Fixed + +- Claude 5 models now use the correct context windows. + +## 0.2.1 - 2026-07-24 + +### Added + +- Claude Opus 5 is available + +## 0.2.0 - 2026-07-24 ### Added - Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx)) -- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223)) -- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr)) +- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang)) +- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223)) +- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs)) +- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs)) +- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr)) +- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu)) +- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324)) +- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger)) +- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160)) +- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257)) +- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue)) - Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119)) - Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951)) ### Improved -- ACP provider catalog updated to the latest registry versions +- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361)) +- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim)) +- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263)) +- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186)) +- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290)) +- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187)) +- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209)) +- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213)) +- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201)) +- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter)) - Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151)) - Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149)) - Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120)) @@ -20,12 +272,34 @@ ### Fixed +- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381)) +- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379)) +- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang)) +- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger)) +- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363)) +- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368)) +- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369)) +- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331)) +- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316)) +- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315)) +- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx)) +- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd)) +- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313)) +- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258)) +- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214)) +- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223)) +- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991)) +- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194)) +- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268)) +- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago)) +- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang)) +- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210)) - Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter)) - Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137)) - Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982)) - Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156)) - Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002)) -- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123)) +- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs)) - Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd)) ## 0.1.110 - 2026-07-16 diff --git a/CLAUDE.md b/CLAUDE.md index a763b616ff5..2792cebf063 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This is an npm workspace monorepo: `docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources. -At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`. +At the start of non-trivial work, list `docs/` and skim anything relevant to the task. | Doc | What's in it | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | @@ -29,11 +29,12 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [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) | Theme tokens — colors, fonts, spacing, radii, icons | +| [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 | @@ -42,9 +43,12 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [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/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | @@ -54,6 +58,31 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [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 + +- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like. +- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it. +- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link. +- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it. +- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example. +- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block. +- **New doc?** Add a row to the table above and link it from the docs that should send readers there. +- Code-level facts belong in comments next to the code, not here. + +### Doc voice + +Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing. + +Do not: + +- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape. +- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical". +- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful". +- Restate something you already said, in different words, for emphasis. +- Hedge with "generally", "typically", or "you may want to" when the answer is "do this". +- Clear your throat: "It's worth noting that", "In order to", "This section covers". ## Quick start @@ -94,23 +123,11 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script: - `npm run lint -- packages/app/src/components/message.tsx` - `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx` -- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts: - - **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client. - - New fields: `.optional()` with a sensible default. - - Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null). - - Removed fields stay accepted (we stop sending them, not stop reading them). - - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" - - Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass. - - Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled. - - `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers. - - **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it. - - **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature. - - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. - - **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site. - - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal. - - **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones. - -- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable. +- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version: + - **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`. + - **Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches. + - **Every shim is tagged.** `// COMPAT(name): added in vX, remove after ` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident. + - **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones. ## Platform gating @@ -148,12 +165,12 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically. - **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`. ``` - components/ - browser-pane.electron.tsx ← Electron implementation - browser-pane.web.tsx ← plain web fallback - browser-pane.tsx ← native fallback + desktop/browser/pane/ + index.electron.tsx ← Electron implementation + index.web.tsx ← plain web fallback + index.tsx ← native fallback ``` - Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation. + Import as `@/desktop/browser/pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation. - **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only. - **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS. - **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index feb2027c2db..8eaaf5c0c71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,56 +1,140 @@ # Contributing to Paseo -Paseo is an opinionated product maintained by one person right now. +Thank you for taking the time to contribute to Paseo. -The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups. +## Philosophy -Contributing 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. +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). -## Becoming a maintainer +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. -There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one. +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. -Here's the progression: +## Report bugs in GitHub issues or Discord -1. Get involved in the community: answer questions in Discord and on GitHub -2. Triage bugs: replicate and help fix them -3. Work on maintainer-approved features +Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH). -The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer. +> [!IMPORTANT] +> Feature requests opened as issues will get closed automatically -Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute. +If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis. + +You may submit a PR to fix a bug, please read the PR guidelines below. + +## Product discussions go in GitHub Discussions or Discord + +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: + +- 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? + +"Can you build X" is not useful because it doesn't tell me about your problem. + +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. + +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. ## Pull requests -✅ Will be accepted +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. + +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. + +Here is the criteria I use to decide: -- Keep it to one focused change -- Link to an issue -- Explain the problem you're solving -- Include repro steps if it's a bug -- Include QA/testing evidence -- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web -- If you only tested one platform, say that clearly +✅ Likely to be accepted + +- 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 ⛔️ Will be rejected -- Bundle unrelated changes -- Fail basic checks like typecheck, formatting or linting -- Add a feature or design change that wasn't discussed first -- Submit no evidence of testing -- Skip the linked issue +- 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 -## Requesting features +### What to expect + +- 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 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. + +Pull requests without evidence will be closed. + +What's accepted: + +- 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 + +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. + +The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it. + +## I want to become a maintainer + +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. + +## FAQ + +### I'm blocked, where do I go? + +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? -If you need a feature implemented, create a Github issue or a thread in Discord. +Yes. Test what you have, and say what you didn't test. -Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect. +### Can I use an agent to write my contribution? -## AI assistance +Yes, as long as you understand what you're submitting and you tested it yourself. -Using AI to help write code is fine, but you must: +### Is there a roadmap I can read? -- Ensure your agents read the docs -- Understand the code you submit -- Review and test the code yourself +[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next. diff --git a/README.ja.md b/README.ja.md index 224885c6bc8..0f35d47c576 100644 --- a/README.ja.md +++ b/README.ja.md @@ -7,7 +7,8 @@

English · 简体中文 · - 日本語 + 日本語 · + 한국어

@@ -117,7 +118,6 @@ npx skills add getpaseo/paseo どのエージェントとの会話でも使用できます。 - `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。 -- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。 - `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。 - `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。 @@ -151,23 +151,12 @@ npm run build:server npm run typecheck ``` -## コミュニティ +## 関連プロジェクト -- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー +- [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 拡張機能 ---- - -

- - - - - getpaseo/paseo のスター履歴チャート - - -

- ## ライセンス AGPL-3.0 diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 00000000000..9975a7dd63a --- /dev/null +++ b/README.ko.md @@ -0,0 +1,172 @@ +

+ Paseo 로고 +

+ +

Paseo

+ +

+ English · + 简体中文 · + 日本語 · + 한국어 +

+ +

+ + GitHub stars + + + GitHub release + + + X + + + Discord + + + Reddit + +

+ +

Claude Code, Codex, Copilot, OpenCode, Pi 에이전트를 위한 하나의 인터페이스

+ +

+ Paseo app screenshot +

+ +

+ Paseo mobile app +

+ +내 컴퓨터에서 에이전트를 병렬로 실행하세요. 데스크톱이나 휴대폰에서 배포하세요. + +- **셀프 호스팅:** 에이전트는 완전한 개발 환경이 갖춰진 내 컴퓨터에서 실행됩니다. 평소 쓰던 도구, 설정, 스킬을 그대로 쓸 수 있습니다. +- **여러 제공자 지원:** Claude Code, Codex, Copilot, OpenCode, Pi를 하나의 인터페이스에서 사용할 수 있습니다. 작업마다 알맞은 모델을 고를 수 있습니다. +- **음성 제어:** 음성 모드에서 작업을 말로 지시하거나 문제를 음성으로 함께 검토할 수 있습니다. 손을 쓰지 않고 작업해야 할 때 유용합니다. +- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다. +- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다. + +## 시작하기 + +Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다. + +### 준비 사항 + +아래 에이전트 CLI 중 하나 이상을 설치하고 인증 정보를 설정해야 합니다. + +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) +- [Codex](https://github.com/openai/codex) +- [GitHub Copilot](https://github.com/features/copilot/cli/) +- [OpenCode](https://github.com/anomalyco/opencode) +- [Pi](https://pi.dev) + +### 데스크톱 앱(권장) + +[paseo.sh/download](https://paseo.sh/download) 또는 [GitHub 릴리스 페이지](https://github.com/getpaseo/paseo/releases)에서 다운로드하세요. 앱을 열면 데몬이 자동으로 시작됩니다. 별도로 설치할 것은 없습니다. + +휴대폰에서 연결하려면 **설정 → 호스트 → 기기 페어링**을 여세요. + +### CLI / 헤드리스 환경 + +CLI를 설치하고 Paseo를 시작하세요. + +```bash +npm install -g @getpaseo/cli +paseo +``` + +Paseo가 로컬에서 시작된 뒤 기기 페어링을 위한 종단 간 암호화 릴레이를 켤지 묻습니다. 거절하면 TCP, Tailscale 또는 다른 VPN으로 직접 연결할 수 있습니다. 이 방식은 서버나 원격 머신에서 유용합니다. + +자세한 설치와 설정은 아래 문서를 참고하세요. + +- [문서](https://paseo.sh/docs) +- [연결 가이드](https://paseo.sh/docs/connectivity) +- [설정 레퍼런스](https://paseo.sh/docs/configuration) + +### Docker + +Docker에서 Paseo 데몬과 셀프 호스팅 웹 UI를 실행하세요: + +```bash +docker run -d --name paseo \ + -p 6767:6767 \ + -e PASEO_PASSWORD=change-me \ + -v "$PWD/paseo-home:/home/paseo" \ + -v "$PWD:/workspace" \ + ghcr.io/getpaseo/paseo:latest +``` + +컨테이너가 시작되면 `http://localhost:6767`을 여세요. 사용하는 에이전트 CLI를 기본 이미지에 추가한 뒤, 환경 변수나 영구 `/home/paseo` 볼륨으로 인증 정보를 설정하세요. 자세한 내용은 [Docker 문서](docs/docker.md)를 참고하세요. + +## CLI + +앱에서 할 수 있는 모든 작업은 터미널에서도 할 수 있습니다. + +```bash +paseo run --provider claude/opus-4.6 "implement user authentication" +paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X" + +paseo ls # 실행 중인 에이전트 목록 +paseo attach abc123 # 실시간 출력 스트리밍 +paseo send abc123 "also add tests" # 후속 작업 전송 + +# 원격 데몬에서 실행 +paseo --host workstation.local:6767 run "run the full test suite" +``` + +자세한 내용은 [전체 CLI 레퍼런스](https://paseo.sh/docs/cli)를 참고하세요. + +## 스킬 + +스킬은 에이전트가 Paseo를 통해 다른 에이전트를 오케스트레이션하는 방법을 알려 줍니다. + +```bash +npx skills add getpaseo/paseo +``` + +그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다. + +- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다. +- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다. +- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다. + +## 개발 + +모노레포 패키지 구성은 다음과 같습니다. + +- `packages/server`: Paseo 데몬(에이전트 프로세스 오케스트레이션, WebSocket API, MCP 서버 제공) +- `packages/app`: Expo 클라이언트(iOS, Android, 웹) +- `packages/cli`: `paseo` CLI(데몬과 에이전트 워크플로) +- `packages/desktop`: Electron 데스크톱 앱 +- `packages/relay`: 데몬과 클라이언트가 쓰는 릴레이 전송 및 암호화 패키지 +- `packages/website`: 마케팅 사이트 및 문서(`paseo.sh`) + +자주 쓰는 명령: + +```bash +# 모든 로컬 개발 서비스 실행 +npm run dev + +# 개별 환경 실행 +npm run dev:server +npm run dev:app +npm run dev:desktop +npm run dev:website + +# 서버 스택 빌드 +npm run build:server + +# 레포 전체 검사 실행 +npm run typecheck +``` + +## 관련 프로젝트 + +- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이 +- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — 커뮤니티 테마와 Agent Skill을 제공하고, 코드 수정 없이 쓸 수 있는 데스크톱 테마 로더 +- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 확장 프로그램 + +## 라이선스 + +AGPL-3.0 diff --git a/README.md b/README.md index d3071915831..1b26f318005 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@

English · 简体中文 · - 日本語 + 日本語 · + 한국어

@@ -38,12 +39,6 @@ Paseo mobile app

-> [!NOTE] -> I'm a solo maintainer and don't always keep up with GitHub Issues daily. -> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me. - ---- - Run agents in parallel on your own machines. Ship from your phone or your desk. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. @@ -70,7 +65,7 @@ You need at least one agent CLI installed and configured with your credentials: Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install. -To connect from your phone, open **Settings → your host → Connections → Pair a device**. +To connect from your phone, open **Settings → your host → Pair Device**. ### CLI / headless @@ -81,11 +76,12 @@ npm install -g @getpaseo/cli paseo ``` -This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines. +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. For full setup and configuration, see: - [Docs](https://paseo.sh/docs) +- [Connectivity guide](https://paseo.sh/docs/connectivity) - [Configuration reference](https://paseo.sh/docs/configuration) ### Docker @@ -109,7 +105,7 @@ Everything you can do in the app, you can do from the terminal. ```bash paseo run --provider claude/opus-4.6 "implement user authentication" -paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X" +paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X" paseo ls # list running agents paseo attach abc123 # stream live output @@ -121,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite" See the [full CLI reference](https://paseo.sh/docs/cli) for more. +## TypeScript SDK + +Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`: + +```ts +import { createPaseoClient } from "@getpaseo/client"; + +const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" }); +await client.connect(); + +const agent = await client.agents.create({ + config: { provider: "codex/gpt-5.5" }, + cwd: "/Users/me/dev/storefront", + prompt: "Review the current diff and name the riskiest change.", +}); + +const result = await agent.waitForFinish(); +console.log(result.lastMessage); + +await client.close(); +``` + +See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference). + ## Skills Skills teach your agent to use Paseo to orchestrate other agents. @@ -132,7 +152,6 @@ npx skills add getpaseo/paseo Then use them in any agent conversation: - `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement. -- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier. - `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself. - `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan. @@ -144,7 +163,7 @@ Quick monorepo package map: - `packages/app`: Expo client (iOS, Android, web) - `packages/cli`: `paseo` CLI for daemon and agent workflows - `packages/desktop`: Electron desktop app -- `packages/relay`: Relay package for remote connectivity +- `packages/relay`: Relay transport and encryption used by the daemon and clients - `packages/website`: Marketing site and documentation (`paseo.sh`) Common commands: @@ -166,23 +185,12 @@ npm run build:server npm run typecheck ``` -## Community +## Related projects -- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go +- [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 ---- - -

- - - - - Star history chart for getpaseo/paseo - - -

- ## License AGPL-3.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index e6baeb8e5af..fef0f4ff2c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,7 +7,8 @@

English · 简体中文 · - 日本語 + 日本語 · + 한국어

@@ -117,7 +118,6 @@ npx skills add getpaseo/paseo 然后在任意 agent 对话中使用: - `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。 -- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。 - `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。 - `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。 @@ -151,9 +151,10 @@ npm run build:server npm run typecheck ``` -## 社区 +## 相关项目 -- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay +- [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 @@ -202,18 +203,6 @@ server { } ``` ---- - -

- - - - - Star history chart for getpaseo/paseo - - -

- ## License AGPL-3.0 diff --git a/SECURITY.md b/SECURITY.md index b09425b6886..d97910122e0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. 3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame. +4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. + +The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. @@ -64,6 +66,18 @@ CORS is not a complete security boundary. It controls which browser origins can Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`. +## HTML file preview + +Previewing an `.html` file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it. + +The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no `fetch`, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself. + +One gap remains on web and desktop: a sandboxed document may navigate _itself_, and no CSP directive in current browsers prevents that. `navigate-to` was dropped from CSP Level 3 and is not enforced, and `` needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies. + +Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too. + +If you don't trust a page, read it in `Source`, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else. + ## Agent authentication Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials. diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index f5e92283034..166a5dbfc8f 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,15 +21,25 @@ 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. -The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only -unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement, -or permission, and have not been activated during the idle window are eligible. `running`, -`initializing`, and `error` agents stay resident. Subagents are considered independently; collection -does not cascade or change parentage. - -Active schedules targeting an existing agent protect that agent from collection. Paused, completed, -and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the -runtime. +Idle agents remain resident while they have recent activity or a client is viewing them. After +`daemon.sessionIdleTimeoutMs` (default 30 minutes, `0` disables) without activity the daemon reaps the +agent: it tears down the provider session — which frees the agent's opencode serve REPL process and +the MCP stack it owns — while keeping the agent registered and listed as `idle` (`cold: true`). An +idle agent is never reaped while it is running, has a pending permission or turn waiter, or a client +is actively viewing it. Re-activation is lazy: prompting, attaching, refreshing, timeline fetch, and +schedules all run through `ensureAgentLoaded()`, which resumes the durable provider session under the +same Paseo agent ID with provider history already primed. Runtime closure of a cold agent's record +happens only through an explicit lifecycle action such as archive, replacement, reload, workspace +teardown, or daemon shutdown. + +A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked +inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and +workflows all live in the CLI process, and the completion notification that would have woken the +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. ### Cancellation @@ -42,11 +52,26 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age - **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. - **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it. +Parent archive detaches a subagent instead of archiving it when either condition holds: + +- The child belongs to another workspace. +- The child is currently open in an agent tab. + +All other children archive with the parent. After the workspace layout hydrates, the client marks +every managed subagent present in its tabs with `paseo.open-agent-tab.=true` through the +generic agent metadata update. This includes background and restored tabs; navigation does not own +the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child +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. -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 `paseo.parent-agent-id` label only: 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. +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. +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. ## Provider-managed child agents @@ -58,9 +83,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t 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. -Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native -archive hook, and cascades to managed children. Runtime collection does none of those things; it only -releases the live runtime and writes `lastStatus: closed` on the still-active record. +Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed +children. `create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone. @@ -70,7 +94,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/ 2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing` 3. Notify subscribers 4. Close the runtime (kills the process if still running) -5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively +5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively Cascade is what keeps subagent fleets from outliving their orchestrator. @@ -80,6 +104,20 @@ 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. + +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, +`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never +received. + ## Tabs vs archive These are two distinct concepts that used to be conflated: @@ -91,7 +129,7 @@ These are two distinct concepts that used to be conflated: Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident. -Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`). +Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy. The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached. @@ -101,6 +139,8 @@ Agent lifecycle status stays literal: a parent agent is `idle` when its own turn Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship. +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. + ## The subagents track The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children: @@ -117,11 +157,25 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int 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. +Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields. + +### Claude provider subagents: the task protocol + +Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types: + +- **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`. +- **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. +- **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. The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track. -To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. +To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. ## Why this shape @@ -155,11 +209,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json Each agent is a single JSON file. Fields relevant to this doc: -| Field | Type | Meaning | -| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- | -| `id` | `string` | Stable identifier | -| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | -| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | -| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | +| Field | Type | Meaning | +| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | +| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | +| `labels["paseo.open-agent-tab."]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/android.md b/docs/android.md index 69a31b5b57d..6e38a587259 100644 --- a/docs/android.md +++ b/docs/android.md @@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1 ## Prerequisites (local dev) -Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev): +Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev): ```bash mise install # java 21 + android-sdk 21.0 command-line tools ``` -> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`. +> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry. `mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon: @@ -67,6 +67,17 @@ npm run android:production # Release build npm run android:clear # Remove generated Android project ``` +For a production-ID release APK that local Android profiling tools can attach to: + +```bash +PASEO_PROFILE_BUILD=1 npm run android:production +``` + +This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds +`` and enables local Android trace markers for workspace mounts +and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents, +and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`). + Or from `packages/app`: ```bash @@ -118,7 +129,17 @@ cd android PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false ``` -The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications. +The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications. + +For a single-ABI APK, pass React Native's architecture property to Gradle: + +```bash +PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \ + -PreactNativeArchitectures=arm64-v8a \ + --no-daemon --max-workers=1 -Dorg.gradle.parallel=false +``` + +Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code. Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules. diff --git a/docs/architecture.md b/docs/architecture.md index ce218985316..2946da8b408 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,7 @@ not retain non-Git directories. | `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | | `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | | `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | +| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents | | `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | | `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | | `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | @@ -76,8 +77,6 @@ not retain non-Git directories. | `server/agent/providers/` | Provider adapters (see "Agent providers" below) | | `server/relay-transport.ts` | Outbound relay connection with E2E encryption | | `server/schedule/` | Cron-based scheduled agents | -| `server/loop-service.ts` | Looping agent runs that retry until an exit condition | -| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging | ### `packages/protocol` — Wire schemas and shared protocol types @@ -99,19 +98,27 @@ 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 a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path. +- `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. - `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/` - Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication - 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 exists only to paint stale data immediately while the host connects. It does not -own mutations, infer deletions, or replace daemon reconciliation. 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 1 MiB byte budget and evicts whole host snapshots in least-recently- -written order; a single oversized host is omitted rather than partially restored. +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. + +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 +event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are +independent records; a project with no workspaces does not need a workspace placeholder. ### `packages/cli` — Command-line client @@ -119,12 +126,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `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 chat ls/create/inspect/post/read/wait/delete` - `paseo terminal ls/create/capture/send-keys/kill` -- `paseo loop run/ls/inspect/logs/stop` +- `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo heartbeat create/update/delete` -- `paseo workspace create/ls/archive` +- `paseo workspace create/ls/rename/archive` - `paseo permit allow/deny/ls` - `paseo provider ls/models` - hidden legacy `paseo worktree create/ls/archive` compatibility alias @@ -132,16 +138,20 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex Communicates with the daemon via the same WebSocket protocol as the app. -### `packages/relay` — E2E encrypted relay +### `packages/relay` — Relay transport and E2E encryption Enables remote access when the daemon is behind a firewall. - Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption -- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content +- 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 +- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot. +- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only - Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS` +The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed. + See [SECURITY.md](../SECURITY.md) for the full threat model. ### Paseo Hub @@ -208,7 +218,9 @@ There is no dedicated welcome message; the server emits a `status` session messa **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 and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. 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 and must not be treated as proof that the socket is dead. + +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. Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default. @@ -222,6 +234,11 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such - `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow - `agent_deleted`, `agent_archived`, `agent_status`, `agent_list` - `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations + +Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events +optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's +status once at the directory boundary rather than maintaining a second activity model. + - Terminal subscribe/input/capture commands - Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`) - Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error` @@ -246,6 +263,10 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport. There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. +File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks +from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before +reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated +messages or transfers. ### Compatibility rules @@ -283,7 +304,10 @@ initializing → idle ⇄ running `ManagedAgent` is a discriminated union over those lifecycle tags. Notes: - **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers -- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items +- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one + permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted + prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for + client-side dedup; the default fetch page is 200 items. - Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics. - Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up. - Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id. @@ -364,9 +388,8 @@ $PASEO_HOME/ ├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows ├── projects/projects.json # Project registry ├── projects/workspaces.json # Workspace registry -├── chat/ # Chat rooms +├── projects/icons/ # Custom project icon images ├── schedules/ # Scheduled-agent definitions and runs -├── loops/ # Loop runs and logs ├── config.json # Daemon config (mutable) ├── daemon-keypair.json # Daemon identity for relay/E2EE ├── push-tokens.json # Mobile push tokens @@ -377,5 +400,5 @@ $PASEO_HOME/ ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` -2. **Managed desktop**: Electron app spawns daemon as subprocess +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`). 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 983801c7eb4..734dda3c45e 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -5,7 +5,7 @@ It validates the compositor behavior that unit tests cannot see: - the resident automation `` starts in the production parking state; - the parked guest remains paintable and has a copyable viewport frame; -- the resident webview guest is sized to 1280x800 logical pixels; +- a never-presented resident webview guest defaults to 1280x800 logical pixels; - multiple resident webviews are parked as an overlapping stack without per-capture stacking changes; - a newly attached resident webview whose first useful frame is delayed can be captured @@ -82,10 +82,19 @@ is usually saved as 2560x1600. Electron captures copy from the guest web contents' compositor surface. A resident webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its -copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`, -`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`. -The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely -overlap at `left:0`, `top:0`. +copyable surface. Each production webview keeps one permanent body-level surface. Presenting +or parking changes that surface's geometry without reparenting the webview. The parking state +uses `left:0`, `top:0`, `width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and +`pointer-events:none`. The webview stays at its resolved logical viewport, defaulting to +1280x800 before first presentation, with `display:inline-flex` at `left:0`, `top:0`. +Presentation resolves responsive guests to the pane's exact pixel dimensions after the surface +has visible bounds. Do not apply percentage guest sizing against the parked surface: Electron +exposes the 1x1 parking geometry as a real guest resize before expanding it again. + +The permanent browser host and `overlay-root` are explicit sibling paint planes. The browser +plane stays below the overlay plane regardless of body insertion order; menus keep their relative +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, diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 31fb96bc485..ec77d103b88 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -14,6 +14,10 @@ For testing rules, see [testing.md](testing.md). - **`interface`** over `type` when both work. - **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source. +## Shell scripts + +- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS. + ## Comments and noise - Delete any comment where removing it loses zero information. Comments explain _why_, not _what_. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index bc20b35d624..924d1bc0664 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -2,7 +2,7 @@ Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider. -All provider configuration lives under `agents.providers` in config.json: +Provider definitions live under `agents.providers` in config.json: ```json { @@ -17,6 +17,20 @@ All provider configuration lives under `agents.providers` in config.json: Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). +Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large +agent catalog during startup, raise the limit in milliseconds: + +```json +{ + "agents": { + "catalogRefreshTimeoutMs": 180000 + } +} +``` + +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. + --- ## Table of Contents @@ -245,6 +259,8 @@ requires_openai_auth = false You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment. +"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word. + Example: two different Anthropic accounts as separate profiles: ```json @@ -584,6 +600,11 @@ When you launch an agent with an ACP provider: 4. Paseo creates a session and sends prompts through the ACP protocol 5. The agent streams responses, tool calls, and permission requests back over stdout +Every ACP provider exposes an **Auto Accept** toggle. Enable it per session to let Paseo approve +ACP permission requests without surfacing each prompt. If the provider sends no allow option, +Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you +explicitly disable it. + Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field: ```json @@ -691,7 +712,7 @@ Each entry in the `models` array: 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`. -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. 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. +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. ### Gotcha: `extends: "claude"` with third-party endpoints diff --git a/docs/data-model.md b/docs/data-model.md index e1841045dd4..bc1d8bd17ba 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,9 +4,15 @@ Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name. -`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and -updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its -ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed +`projectKey` is a persisted, opaque equivalence key used only to group the same logical project +across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized +Git remote and otherwise uses the local project root. Consumers never derive it from live Git. +Creation persists it with the project, and normal boot reconciliation fills it for +older records where the field is absent—there is no migration. + +`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and +updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path, +names, and workspace foreign keys. Attached workspaces are independently refreshed from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects are observed too. @@ -48,13 +54,10 @@ $PASEO_HOME/ │ └── {agentId}.json # One file per agent ├── schedules/ │ └── {scheduleId}.json # One file per schedule -├── chat/ -│ └── rooms.json # All rooms + messages -├── loops/ -│ └── loops.json # All loop records ├── projects/ │ ├── projects.json # Project registry -│ └── workspaces.json # Workspace registry +│ ├── workspaces.json # Workspace registry +│ └── icons/ # Host-local custom project icon images ├── runtime/ │ └── managed-processes/ │ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap @@ -82,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `title` | `string?` | User-visible title | -| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `labels` | `Record` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) | | `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. | | `lastModeId` | `string?` | Last active mode ID | | `config` | `SerializableConfig?` | Agent session configuration (see below) | @@ -93,7 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `requiresAttention` | `boolean?` | Whether the agent needs user attention | | `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | | `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | -| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `internal` | `boolean?` | Whether this is a system-internal agent | | `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | ### Nested: SerializableConfig @@ -180,9 +183,12 @@ Single file, validated with `PersistedConfigSchema`. hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs mcp: { enabled: boolean, injectIntoAgents: boolean }, + git: { maxProcessesPerSecond: number, maxProcessConcurrency: number }, appendSystemPrompt: string, // appended to supported provider system/developer prompts + terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES + agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none cors: { allowedOrigins: string[] }, - relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, + relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false auth: { password: string } // bcrypt hash, optional }, app: { @@ -227,6 +233,55 @@ Single file, validated with `PersistedConfigSchema`. All fields are optional with sensible defaults. +### Profile lists + +`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the +array, never merges entries, so a client sends the complete next list on every add, edit, reorder +and remove. List order is the display order. + +Absent and empty mean different things for terminal profiles — omitting the key falls back to +`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no +defaults, so both mean none. + +`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write +rather than storing something it cannot describe. That is why the client gates the agent profiles +UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an +older daemon. + +### Git process limits + +Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per +second, and the concurrency limit defaults to `8`: + +```json +{ + "daemon": { + "git": { + "maxProcessesPerSecond": 64, + "maxProcessConcurrency": 8 + } + } +} +``` + +`maxProcessesPerSecond` limits Git process starts in any one-second interval. The allowance can +start as a burst; it does not wait for earlier processes to exit. `maxProcessConcurrency` limits +the number of Git processes that have started but not exited. Every Git command uses both limits, +including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit +requests. + +Environment variables override `config.json`: + +| Environment variable | Setting | +| ------------------------------------ | ------------------------ | +| `PASEO_GIT_MAX_PROCESSES_PER_SECOND` | `maxProcessesPerSecond` | +| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` | +| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias | + +`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the +daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon. +For a desktop-managed daemon, fully quit and reopen Paseo Desktop. + `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. Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. @@ -299,7 +354,7 @@ One file per schedule. ID is 8 hex characters. ### Nested: ScheduleTarget (discriminated union on `type`) - `{ type: "agent", agentId: string }` — send to existing agent -- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent +- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }` — create a new agent ### Nested: ScheduleRun @@ -316,148 +371,30 @@ One file per schedule. ID is 8 hex characters. --- -## 4. Chat - -**Path:** `$PASEO_HOME/chat/rooms.json` - -Single file containing all rooms and messages. - -```json -{ - "rooms": [ ... ], - "messages": [ ... ] -} -``` - -### ChatRoom - -| Field | Type | Description | -| ----------- | ------------------- | ----------------------------------- | -| `id` | `string` (UUID) | | -| `name` | `string` | Unique room name (case-insensitive) | -| `purpose` | `string?` | Room description | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | Updated on each new message | - -### ChatMessage - -| Field | Type | Description | -| ------------------ | ------------------- | ----------------------------------- | -| `id` | `string` (UUID) | | -| `roomId` | `string` | FK to ChatRoom.id | -| `authorAgentId` | `string` | Agent ID of the author | -| `body` | `string` | Message text (supports `@mentions`) | -| `replyToMessageId` | `string?` | FK to another ChatMessage.id | -| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs | -| `createdAt` | `string` (ISO 8601) | | - ---- - -## 5. Loop - -**Path:** `$PASEO_HOME/loops/loops.json` - -Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry. - -| Field | Type | Description | -| ----------------------- | --------------------------------------------------- | ------------------------------------------ | -| `id` | `string` | 8-char UUID prefix | -| `name` | `string?` | Human-readable name | -| `prompt` | `string` | Worker prompt | -| `cwd` | `string` | Working directory | -| `provider` | `string` | Default provider | -| `model` | `string?` | Default model | -| `modeId` | `string?` | Default mode ID | -| `workerProvider` | `string?` | Override provider for workers | -| `workerModel` | `string?` | Override model for workers | -| `verifierProvider` | `string?` | Override provider for verifiers | -| `verifierModel` | `string?` | Override model for verifiers | -| `verifierModeId` | `string?` | Override mode ID for verifiers | -| `verifyPrompt` | `string?` | LLM verification prompt | -| `verifyChecks` | `string[]` | Shell commands to run as checks | -| `archive` | `boolean` | Whether to archive worker agents after use | -| `sleepMs` | `number` | Delay between iterations (ms) | -| `maxIterations` | `number?` | Cap on iterations | -| `maxTimeMs` | `number?` | Total time budget (ms) | -| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `startedAt` | `string` (ISO 8601) | | -| `completedAt` | `string?` (ISO 8601) | | -| `stopRequestedAt` | `string?` (ISO 8601) | | -| `iterations` | `LoopIteration[]` | | -| `logs` | `LoopLogEntry[]` | | -| `nextLogSeq` | `number` | Monotonic log sequence counter | -| `activeIteration` | `number?` | Currently executing iteration index | -| `activeWorkerAgentId` | `string?` | Currently running worker agent | -| `activeVerifierAgentId` | `string?` | Currently running verifier agent | - -### Nested: LoopIteration - -| Field | Type | Description | -| ------------------- | --------------------------------------------------- | ------------------------ | -| `index` | `number` | 1-based iteration index | -| `workerAgentId` | `string?` | Agent ID of the worker | -| `workerStartedAt` | `string` (ISO 8601) | | -| `workerCompletedAt` | `string?` (ISO 8601) | | -| `verifierAgentId` | `string?` | Agent ID of the verifier | -| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | -| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | | -| `failureReason` | `string?` | | -| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results | -| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result | - -### Nested: LoopLogEntry - -| Field | Type | -| ----------- | ---------------------------------------------------- | -| `seq` | `number` (monotonic) | -| `timestamp` | `string` (ISO 8601) | -| `iteration` | `number?` | -| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` | -| `level` | `"info" \| "error"` | -| `text` | `string` | - -### Nested: LoopVerifyCheckResult - -| Field | Type | -| ------------- | ------------------- | -| `command` | `string` | -| `exitCode` | `number` | -| `passed` | `boolean` | -| `stdout` | `string` | -| `stderr` | `string` | -| `startedAt` | `string` (ISO 8601) | -| `completedAt` | `string` (ISO 8601) | - -### Nested: LoopVerifyPromptResult - -| Field | Type | -| ----------------- | ------------------- | -| `passed` | `boolean` | -| `reason` | `string` | -| `verifierAgentId` | `string?` | -| `startedAt` | `string` (ISO 8601) | -| `completedAt` | `string` (ISO 8601) | - ---- - -## 6. Project Registry +## 4. Project Registry **Path:** `$PASEO_HOME/projects/projects.json` Array of project records. -| Field | Type | Description | -| ------------- | --------------------------- | -------------------------------------------------------------------------------- | -| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs | -| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | -| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | -| `displayName` | `string` | Selected-root basename, stable across remote and Git changes | -| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | +| Field | Type | Description | +| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs | +| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values | +| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | +| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | +| `displayName` | `string` | Selected-root basename, stable across remote and Git changes | +| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | +| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | + +Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom +icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never +receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new +`customIconRevision`. Going back to automatic deletes the stored image, as does removing the +project. Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never @@ -469,29 +406,30 @@ workspace together with its owning project. --- -## 7. Workspace Registry +## 5. Workspace Registry **Path:** `$PASEO_HOME/projects/workspaces.json` 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 | -| `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` | 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. | +| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | > **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. @@ -502,7 +440,7 @@ than treating it as valid. --- -## 8. Push Token Store +## 6. Push Token Store **Path:** `$PASEO_HOME/push-tokens.json` @@ -516,7 +454,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil --- -## 9. Daemon meta files +## 7. Daemon meta files These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination. diff --git a/docs/design.md b/docs/design.md index 790465389fa..54dd6a09ed3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -98,7 +98,7 @@ Five primitives. The pick is determined by option count, the need to search, and `` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these. -`` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container. +`` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container. `confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work. @@ -122,7 +122,21 @@ The whitespace is the design. --- -## 8. Responsiveness +## 8. Alignment + +Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background. + +Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset. + +The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. + +Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical. + +One row off the rail makes the whole card look unconsidered. + +--- + +## 9. Responsiveness Compact-first. The small case is designed; the large case adds chrome around it. @@ -143,7 +157,7 @@ A new list+detail feature copies the settings shell. A new workspace-shaped feat --- -## 9. Copy and voice +## 10. Copy and voice Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong. @@ -165,7 +179,7 @@ Terminology: --- -## 10. States +## 11. States Loading is inline by default. `` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use ``; the menu item handles its own pending state. @@ -185,9 +199,11 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`. +Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm. + --- -## 11. List rows +## 12. List rows The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`). @@ -203,24 +219,30 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th --- -## 12. Status pills and badges +## 13. Status pills and badges + +There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list. + +Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue. + +Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`. -Status pills are `palette.[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical. +Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill. -Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill. +Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band. The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses ``. --- -## 13. Forbidden +## 14. Forbidden - `fontWeight.medium` on row titles, body text, button labels, badge text, or `` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `` is responsive `400/300` and is never overridden. - `` wrapping `` to make a button. ` + ) : null} + + ); +} + +function ModelSearchEmptyState() { + const { t } = useTranslation(); + return ( + + + {t("modelSelector.noMatches")} + + ); +} + +function ProviderModelBrowserContent({ + view, + provider, + profiles, + selectedProvider, + selectedModel, + normalizedQuery, + onSelect, + onApplyProfile, + onEditProfiles, + onRetryProvider, + isRetryingProvider, + scrolling, +}: { + view: Extract; + provider: ProviderSelectorProvider | null; + profiles: AgentProfilePicker | null; + selectedProvider: string; + selectedModel: string; + normalizedQuery: string; + onSelect: (provider: string, modelId: string) => void; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider: boolean; + scrolling: "sheet" | "independent"; +}) { + const { t } = useTranslation(); + const visibleRows = useMemo( + () => (provider ? filterAndRankModelRows(getProviderModelRows(provider), normalizedQuery) : []), + [normalizedQuery, provider], + ); + const providerProfileRows = useMemo( + () => profiles?.rows.filter((row) => row.provider === view.providerId) ?? [], + [profiles, view.providerId], + ); + const profileHeader = useMemo( + () => + normalizedQuery.length === 0 && profiles ? ( + + ) : undefined, + [normalizedQuery, onApplyProfile, onEditProfiles, profiles, providerProfileRows], + ); + + if (!provider) return ; + const selection = provider.modelSelection; + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + if (selection.kind === "error") { + return ( + + ); + } + if (visibleRows.length === 0) { + return profileHeader ?? ; + } + return ( + + ); +} + +function ModelBrowserContent({ + view, + providers, + selectedProvider, + selectedModel, + searchQuery, + profiles, + onSelect, + onApplyProfile, + onEditProfiles, + onDrillDown, + onRetryProvider, + isRetryingProvider = false, + scrolling, +}: ModelBrowserContentProps) { + const { t } = useTranslation(); + const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); + const selectedViewProvider = useMemo( + () => + view.kind === "provider" + ? (providers.find((provider) => provider.id === view.providerId) ?? null) + : null, + [providers, view], + ); + const allView = useMemo( + () => resolveModelBrowserAllView({ providers, normalizedQuery }), + [normalizedQuery, providers], + ); + const hasResults = profiles !== null || providers.length > 0; + + if (view.kind === "provider") { + return ( + + ); + } + + if (allView.kind === "noSearchMatches") { + return ( + + + + {t("modelSelector.noMatchesForQuery", { query: searchQuery.trim() })} + + + ); + } + + if (allView.kind === "searchResults") { + return ( + + ); + } + + const allProvidersContent = ( + + {profiles ? ( + + ) : null} + {providers.length > 0 ? ( + + {profiles ? ( + + {t("modelSelector.providers")} + + ) : null} + + + ) : null} + {!hasResults ? : null} + + ); + + return scrolling === "independent" ? ( + {allProvidersContent} + ) : ( + allProvidersContent + ); +} + +export function ModelBrowser({ + state, + onSelect, + onApplyProfile, + onEditProfiles, + onRetryProvider, + isRetryingProvider = false, + scrolling = "sheet", +}: ModelBrowserProps) { + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerActionRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + profilesContainer: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + sectionHeading: { + position: "relative", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: isWeb ? theme.spacing[3] : theme.spacing[6], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + sectionHeadingAction: { + position: "absolute", + top: theme.spacing[1], + right: isWeb ? theme.spacing[3] : theme.spacing[6], + }, + sectionHeadingText: { + flex: 1, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + browserRow: { + flexDirection: "row", + paddingVertical: theme.spacing[2], + minHeight: 36, + }, + browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] }, + browserRowHovered: { + backgroundColor: theme.colors.surface1, + }, + browserRowHoveredElevated: { + backgroundColor: theme.colors.surface2, + }, + browserRowPressed: { + backgroundColor: theme.colors.surface1, + }, + browserRowPressedElevated: { + backgroundColor: theme.colors.surface2, + }, + browserRowContent: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: isWeb ? theme.spacing[3] : theme.spacing[6], + }, + browserRowLeading: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + browserRowText: { + flex: 1, + flexShrink: 1, + }, + browserRowTextInline: { + flexDirection: "row", + alignItems: "baseline", + gap: theme.spacing[2], + }, + browserRowLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + flexShrink: 0, + }, + browserRowLabelMuted: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 0, + }, + browserRowDescription: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + browserRowTrailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + marginLeft: "auto", + }, + browserRowSelection: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + drillDownTrailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + drillDownCount: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + rowStateInline: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 1, + minWidth: 0, + }, + rowIconButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + rowSpinner: { + transform: [{ scale: 0.7 }], + }, + rowIconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + rowIconButtonPressed: { + backgroundColor: theme.colors.surface1, + }, + emptyState: { + paddingVertical: theme.spacing[4], + alignItems: "center", + gap: theme.spacing[2], + }, + emptyStateText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + tooltipText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + }, + virtualizedModelList: { + flex: 1, + }, + virtualizedModelListContent: { + paddingTop: theme.spacing[1], + paddingBottom: theme.spacing[8], + }, + virtualizedProviderListContent: { + paddingTop: 0, + }, + providerIconMuted: { + color: theme.colors.foregroundMuted, + }, + providerIconForeground: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx new file mode 100644 index 00000000000..a2790cb432b --- /dev/null +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -0,0 +1,286 @@ +import { Buffer } from "buffer"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ProjectIconSource } from "@getpaseo/protocol/messages"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { Button } from "@/components/ui/button"; +import type { FieldControlSize } from "@/components/ui/control-geometry"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { useFilePicker } from "@/hooks/use-file-picker"; +import { + openProjectEditForm, + type ProjectEditFormError, + type ProjectEditFormSnapshot, + type ProjectEditSubmission, + type ProjectIconIntent, +} from "@/projects/edit-form"; +import { importProjectIconFromUrl } from "@/projects/import-project-icon"; +import { toErrorMessage } from "@/utils/error-messages"; + +export interface ProjectEditSheetProps { + visible: boolean; + onClose: () => void; + serverId: string; + projectId: string; + projectViewKey: string; + client: DaemonClient; + /** False on hosts that predate custom project icons — the icon field is hidden. */ + supportsCustomIcon: boolean; + snapshot: ProjectEditFormSnapshot; +} + +/** Editing a project is its name and its icon, decided together and saved once. */ +export function ProjectEditSheet({ + visible, + onClose, + projectId, + projectViewKey, + client, + supportsCustomIcon, + snapshot, +}: ProjectEditSheetProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const toast = useToast(); + const { pickFiles } = useFilePicker(); + const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; + const [form] = useState(() => openProjectEditForm(snapshot)); + const state = useSyncExternalStore(form.subscribe, form.getState, form.getState); + + const mutation = useMutation({ + mutationFn: (submission: ProjectEditSubmission) => + submitProjectEdit({ client, projectId, submission }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.show(t("settings.project.edit.savedToast"), { variant: "success" }); + onClose(); + }, + onError: (error) => form.setError(toFormError(error)), + }); + const isSaving = mutation.isPending; + + const handleClose = useCallback(() => { + if (isSaving) return; + onClose(); + }, [isSaving, onClose]); + + const handleSubmit = useCallback(() => { + mutation.mutate(state.submission); + }, [mutation, state.submission]); + + const chooseImage = useCallback(async () => { + const file = (await pickFiles())?.[0]; + if (!file) return; + form.setPickedImage({ + fileName: file.fileName, + mimeType: file.mimeType, + data: Buffer.from(file.bytes).toString("base64"), + }); + }, [form, pickFiles]); + + const handleChooseImage = useCallback(() => { + void chooseImage(); + }, [chooseImage]); + + const header = useMemo(() => ({ title: t("settings.project.edit.title") }), [t]); + + const footer = useMemo( + () => ( + + + + + ), + [handleClose, handleSubmit, isSaving, state.canSubmit, t], + ); + + return ( + + + + + + {supportsCustomIcon ? ( + + + + + + {state.canUseAutomatic ? ( + + ) : null} + + + + + ) : null} + + ); +} + +function projectInitial(projectName: string): string { + return projectName.trim().charAt(0).toUpperCase() || "?"; +} + +class ProjectEditStepError extends Error { + constructor( + readonly scope: ProjectEditFormError["scope"], + cause: unknown, + ) { + super(toErrorMessage(cause)); + this.name = "ProjectEditStepError"; + } +} + +async function step(scope: ProjectEditFormError["scope"], run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw new ProjectEditStepError(scope, error); + } +} + +/** + * One submit, up to two RPCs. Icon bytes are acquired before either RPC runs + * because that is the step most likely to fail; past that point both RPCs take + * the same inputs on a retry, so a failure between them is safe to submit again. + */ +async function submitProjectEdit(input: { + client: DaemonClient; + projectId: string; + submission: ProjectEditSubmission; +}): Promise { + const { client, projectId, submission } = input; + const { rename, icon } = submission; + + const source = icon ? await step("icon", () => acquireIconSource(icon)) : null; + if (rename) { + await step("name", () => client.renameProject(projectId, rename.customName)); + } + if (source) { + await step("icon", () => client.setProjectIcon(projectId, source)); + } +} + +function acquireIconSource(intent: ProjectIconIntent): Promise { + if (intent.type === "url") return importProjectIconFromUrl(intent.url); + return Promise.resolve(intent); +} + +function toFormError(error: unknown): ProjectEditFormError { + if (error instanceof ProjectEditStepError) { + return { scope: error.scope, message: error.message }; + } + return { scope: "name", message: toErrorMessage(error) }; +} + +const styles = StyleSheet.create((theme) => ({ + iconField: { + gap: theme.spacing[2], + }, + iconRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + previewText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + footer: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, + footerButton: { + minWidth: 112, + }, +})); diff --git a/packages/app/src/components/project-icon-image.tsx b/packages/app/src/components/project-icon-image.tsx new file mode 100644 index 00000000000..6e9345939d7 --- /dev/null +++ b/packages/app/src/components/project-icon-image.tsx @@ -0,0 +1,55 @@ +import { Buffer } from "buffer"; +import { type ReactElement, useMemo } from "react"; +import { Image, type ImageStyle, type StyleProp, View } from "react-native"; +import { SvgCss } from "react-native-svg/css"; + +const SVG_MIME_TYPE = "image/svg+xml"; +const ICO_MIME_TYPES = new Set(["image/x-icon", "image/vnd.microsoft.icon"]); +const SVG_CONTAINER = { overflow: "hidden" } as const; + +export interface ProjectIconImageProps { + dataUri: string; + fallback: ReactElement; + style: StyleProp; +} + +function parseDataUri(dataUri: string): { mimeType: string; payload: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(dataUri); + if (!match) return null; + + const [, mimeType, payload] = match; + return mimeType && payload ? { mimeType: mimeType.toLowerCase(), payload } : null; +} + +function ignoreSvgParseError() { + // Invalid repository SVGs use the same fallback as unsupported icon formats. +} + +export function ProjectIconImage({ dataUri, fallback, style }: ProjectIconImageProps) { + const source = useMemo(() => ({ uri: dataUri }), [dataUri]); + const parts = useMemo(() => parseDataUri(dataUri), [dataUri]); + const svgXml = useMemo( + () => + parts?.mimeType === SVG_MIME_TYPE + ? Buffer.from(parts.payload, "base64").toString("utf8") + : null, + [parts], + ); + const svgContainerStyle = useMemo(() => [style, SVG_CONTAINER], [style]); + + if (svgXml) { + return ( + + + + ); + } + if (parts && ICO_MIME_TYPES.has(parts.mimeType)) return fallback; + return ; +} diff --git a/packages/app/src/components/project-icon-image.web.tsx b/packages/app/src/components/project-icon-image.web.tsx new file mode 100644 index 00000000000..8e5cad91938 --- /dev/null +++ b/packages/app/src/components/project-icon-image.web.tsx @@ -0,0 +1,8 @@ +import { useMemo } from "react"; +import { Image } from "react-native"; +import type { ProjectIconImageProps } from "@/components/project-icon-image"; + +export function ProjectIconImage({ dataUri, style }: ProjectIconImageProps) { + const source = useMemo(() => ({ uri: dataUri }), [dataUri]); + return ; +} diff --git a/packages/app/src/components/project-icon-view.tsx b/packages/app/src/components/project-icon-view.tsx index 95bc886cbfa..3a31da5fff1 100644 --- a/packages/app/src/components/project-icon-view.tsx +++ b/packages/app/src/components/project-icon-view.tsx @@ -1,45 +1,73 @@ import { useMemo } from "react"; -import { - Image, - type ImageStyle, - type StyleProp, - Text, - type TextStyle, - View, - type ViewStyle, -} from "react-native"; -import { deriveProjectIconColor } from "@/utils/project-icon-color"; +import { type StyleProp, Text, type TextStyle, View } from "react-native"; +import { ProjectIconImage } from "@/components/project-icon-image"; +import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors"; const WHITE_TEXT = { color: "#ffffff" } as const; +const FALLBACK_LAYOUT = { alignItems: "center", justifyContent: "center" } as const; + +/** + * Corner radius of the *generated* project icon — the colored square with an initial — as a + * fraction of the box, so it reads as the same shape at 16pt in the sidebar and 40pt in the edit + * sheet. Fixed tokens did not give that: the radius scale is coarse at the bottom, so small icons + * landed on 2pt and looked square while large ones were visibly rounder. + * + * This is ours to shape. A **user-uploaded icon is never rounded** — it is someone's mark, and + * clipping its corners distorts branding we don't own. A square logo stays square, a round one is + * already round. + */ +const RADIUS_RATIO = 0.25; + +export function projectIconRadius(size: number): number { + return Math.round(size * RADIUS_RATIO); +} + +/** + * A project's icon: its chosen image, or a colored square carrying its initial. + * + * Geometry lives here, not at the call site. It used to be five copies of the same + * width/height/radius/centering block, which is how the radius drifted apart in the first + * place — pass a `size` and the shape follows. + */ export function ProjectIconView({ iconDataUri, initial, - projectKey, - imageStyle, - fallbackStyle, + projectViewKey, + size, textStyle, }: { iconDataUri: string | null; initial: string; - projectKey: string; - imageStyle: StyleProp; - fallbackStyle: StyleProp; + projectViewKey: string; + size: number; textStyle: StyleProp; }) { - const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); + // The uploaded image is sized but never clipped — see projectIconRadius. + const box = useMemo(() => ({ width: size, height: size }), [size]); const fallbackStyles = useMemo( - () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }], - [fallbackStyle, projectKey], + () => [ + box, + { borderRadius: projectIconRadius(size) }, + FALLBACK_LAYOUT, + { backgroundColor: identityColor(deriveIdentityColorName(projectViewKey)) }, + ], + [box, size, projectViewKey], ); const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]); - if (iconDataUri) { - return ; - } - return ( - - {initial} - + const fallback = useMemo( + () => ( + + {initial} + + ), + [fallbackStyles, initial, textStyles], + ); + + return iconDataUri ? ( + + ) : ( + fallback ); } diff --git a/packages/app/src/components/provider-diagnostic-models.test.ts b/packages/app/src/components/provider-diagnostic-models.test.ts index 37159c75a71..4ad536945f8 100644 --- a/packages/app/src/components/provider-diagnostic-models.test.ts +++ b/packages/app/src/components/provider-diagnostic-models.test.ts @@ -42,6 +42,23 @@ describe("resolveProviderDiscoveredModels", () => { expect(refreshing.models).toEqual([grokModel]); }); + it("excludes compatibility-only models from display and cache", () => { + const compatibilityModel: AgentModelDefinition = { + ...piModel, + id: "pi/model-legacy", + label: "Pi Model legacy", + isSelectable: false, + }; + + const result = resolveModels({ + provider: "pi", + currentModels: [piModel, compatibilityModel], + }); + + expect(result.models).toEqual([piModel]); + expect(result.cache?.models).toEqual([piModel]); + }); + it("does not show one provider's cached models while another provider loads", () => { const ready = resolveModels({ provider: "pi", currentModels: [piModel] }); diff --git a/packages/app/src/components/provider-diagnostic-models.ts b/packages/app/src/components/provider-diagnostic-models.ts index d0368a43f1e..066fbe56833 100644 --- a/packages/app/src/components/provider-diagnostic-models.ts +++ b/packages/app/src/components/provider-diagnostic-models.ts @@ -1,4 +1,5 @@ import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; export interface ProviderDiscoveredModelsCache { serverId: string; @@ -26,9 +27,10 @@ export function resolveProviderDiscoveredModels({ providerSnapshotRefreshing, previousCache, }: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult { - if (currentModels && currentModels.length > 0) { - const cache = { serverId, provider, models: currentModels }; - return { models: currentModels, cache }; + const selectableModels = filterSelectableModels(currentModels ?? null) ?? []; + if (selectableModels.length > 0) { + const cache = { serverId, provider, models: selectableModels }; + return { models: selectableModels, cache }; } if ( diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index b4831acac5a..e77d20e3fc1 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -3,13 +3,7 @@ import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-re import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Pressable, - type PressableStateCallbackType, - Text, - View, -} from "react-native"; +import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, @@ -29,7 +23,7 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; import { resolveProviderLabel } from "@/utils/provider-definitions"; import { formatTimeAgo } from "@/utils/time"; -import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; +import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types"; import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config"; import { @@ -365,7 +359,7 @@ function DiagnosticSubSheet({ body = ( - + {t("settings.providers.diagnostic.running")} @@ -504,7 +498,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) { if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) { return ( - + {t("settings.providers.models.loading")} ); diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx index 7518fc28f5d..f6ff75c74e0 100644 --- a/packages/app/src/components/provider-settings-host.tsx +++ b/packages/app/src/components/provider-settings-host.tsx @@ -1,11 +1,13 @@ import { useCallback } from "react"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { OverlayLayerProvider } from "@/lib/overlay-root"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; export function ProviderSettingsHost() { const serverId = useProviderSettingsStore((state) => state.serverId); const provider = useProviderSettingsStore((state) => state.provider); const visible = useProviderSettingsStore((state) => state.visible); + const overlayParentLayer = useProviderSettingsStore((state) => state.overlayParentLayer); const close = useProviderSettingsStore((state) => state.close); const handleClose = useCallback(() => { @@ -17,12 +19,14 @@ export function ProviderSettingsHost() { } return ( - + + + ); } diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 22576a7e048..a1c19df69d1 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,12 +1,6 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo } from "react"; -import { - View, - Text, - TextInput, - Pressable, - ActivityIndicator, - type PressableStateCallbackType, -} from "react-native"; +import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; @@ -581,7 +575,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi testID="question-form-dismiss" > {respondingAction === "dismiss" ? ( - + ) : ( @@ -599,7 +593,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi testID="question-form-primary-action" > {respondingAction === "submit" ? ( - + ) : ( diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 31ec826af7f..88b2e378b41 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -1,6 +1,7 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; import { FOOTER_HEIGHT } from "@/constants/layout"; @@ -75,7 +76,7 @@ export function RealtimeVoiceOverlay({ style={stopButtonStyle} > {isSwitching ? ( - + ) : ( ({ + runtime: { breakpoint: "xs" }, + theme: { + spacing: { 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6, lg: 8, xl: 12 }, + fontFamily: { ui: "system-ui", mono: "monospace" }, + fontSize: { xs: 11, code: 12, sm: 13, base: 15, xl: 20 }, + fontWeight: { normal: "400", medium: "500", semibold: "600" }, + colors: { + accent: "#369", + accentForeground: "#fff", + border: "#333", + borderAccent: "#444", + destructive: "#c33", + foreground: "#fff", + foregroundMuted: "#aaa", + surface0: "#111", + surface1: "#181818", + surface2: "#222", + surface3: "#333", + }, + opacity: { 50: 0.5 }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme, rt: runtime }), + withUnistyles: (component: unknown) => component, +})); + +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 47, right: 3, bottom: 34, left: 5 }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +let root: Root | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.unstubAllGlobals(); +}); + +describe("RootErrorFallback", () => { + for (const breakpoint of ["xs", "lg"]) { + it(`keeps the safe-area header and retry action outside the scrollable details on ${breakpoint}`, () => { + runtime.breakpoint = breakpoint; + act(() => { + root?.render(); + }); + + const screen = document.querySelector('[data-testid="root-error-boundary"]'); + const header = document.querySelector('[data-testid="root-error-boundary-header"]'); + const details = document.querySelector('[data-testid="root-error-boundary-details"]'); + const retry = document.querySelector('[data-testid="root-error-boundary-retry"]'); + + expect(screen).not.toBeNull(); + expect(header).not.toBeNull(); + expect(details).not.toBeNull(); + expect(retry).not.toBeNull(); + expect(screen?.getAttribute("style")).toContain("padding: 47px 3px 34px 5px"); + expect(details?.contains(header)).toBe(false); + expect(details?.contains(retry)).toBe(false); + expect(screen?.contains(retry)).toBe(true); + }); + } +}); diff --git a/packages/app/src/components/root-error-boundary.tsx b/packages/app/src/components/root-error-boundary.tsx index 97c34386bba..aa70749d6a1 100644 --- a/packages/app/src/components/root-error-boundary.tsx +++ b/packages/app/src/components/root-error-boundary.tsx @@ -1,8 +1,11 @@ import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react"; -import { Pressable, ScrollView, Text, View } from "react-native"; -import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native"; +import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { formatCaughtValue } from "./root-error-details"; interface RootErrorBoundaryProps { @@ -48,110 +51,113 @@ export class RootErrorBoundary extends Component void; } -function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { +export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const isCompact = useIsCompactFormFactor(); + + const retry = ( + + ); return ( - - {t("rootError.kicker")} - {t("rootError.title")} - {t("rootError.body")} - - {t("rootError.details")} - {error} + + {t("rootError.title")} + {t("rootError.body")} + + + {t("rootError.details")} + + {error} + - - {t("common.actions.retry")} - + {isCompact ? null : retry} - + {isCompact ? {retry} : null} + ); } -function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp { - return [styles.retryButton, pressed ? styles.retryButtonPressed : null]; -} - const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, - contentContainer: { - flexGrow: 1, - justifyContent: "center", - paddingHorizontal: theme.spacing[6], - paddingVertical: theme.spacing[8], - }, content: { + flex: 1, alignSelf: "center", width: "100%", - maxWidth: 520, - gap: theme.spacing[4], + maxWidth: 420, + justifyContent: "center", + gap: theme.spacing[6], + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[8], }, - kicker: { - color: theme.colors.destructive, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, + header: { + alignItems: "center", + gap: theme.spacing[2], }, title: { color: theme.colors.foreground, fontSize: theme.fontSize.xl, - fontWeight: theme.fontWeight.semibold, + fontWeight: theme.fontWeight.medium, + textAlign: "center", }, body: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - lineHeight: 22, + fontSize: theme.fontSize.sm, + lineHeight: 20, + textAlign: "center", }, - messageBox: { + details: { + flexShrink: 1, gap: theme.spacing[2], - borderWidth: 1, - borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, - padding: theme.spacing[4], }, - messageLabel: { + detailsLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, + fontWeight: theme.fontWeight.medium, }, - message: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - lineHeight: 20, + detailsSurface: { + flexShrink: 1, }, - retryButton: { - alignSelf: "flex-start", - minHeight: 40, - justifyContent: "center", - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.accent, - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[2], - }, - retryButtonPressed: { - opacity: 0.85, + detailsScroll: { + flexShrink: 1, }, - retryButtonText: { - color: theme.colors.accentForeground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, + footer: { + alignSelf: "center", + width: "100%", + maxWidth: 420, + paddingHorizontal: theme.spacing[6], + paddingBottom: theme.spacing[6], }, })); diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index febfbf6cae7..7931b95481f 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -33,7 +33,7 @@ import { type SelectFieldOption, type SelectFieldRenderOptionInput, } from "@/components/ui/select-field"; -import { formatThinkingOptionLabel } from "@/composer/agent-controls/utils"; +import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { mergeProviderPreferences, useFormPreferences, diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index faf491a35db..8c2e50ba811 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -384,12 +384,12 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", gap: theme.spacing[3], }, leading: { width: PROVIDER_ICON_SIZE, - height: PROVIDER_ICON_SIZE, + height: 20, alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/components/sidebar-agent-list-skeleton.tsx b/packages/app/src/components/sidebar-agent-list-skeleton.tsx index fb1965df52b..9214bbd4285 100644 --- a/packages/app/src/components/sidebar-agent-list-skeleton.tsx +++ b/packages/app/src/components/sidebar-agent-list-skeleton.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useRef } from "react"; import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { projectIconRadius } from "@/components/project-icon-view"; + +const PROJECT_ICON_SIZE = 16; const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4]; const SECTION_KEYS = SECTION_OPACITIES.map((_, i) => `skeleton-section-${i}`); @@ -116,9 +119,10 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, projectIcon: { - width: 16, - height: 16, - borderRadius: theme.borderRadius.sm, + width: PROJECT_ICON_SIZE, + height: PROJECT_ICON_SIZE, + // Matches the real icon it stands in for, so the skeleton doesn't change shape on load. + borderRadius: projectIconRadius(PROJECT_ICON_SIZE), backgroundColor: theme.colors.surface2, }, sectionTitle: { diff --git a/packages/app/src/components/sidebar-resize-handle-layout.test.ts b/packages/app/src/components/sidebar-resize-handle-layout.test.ts new file mode 100644 index 00000000000..909f82965b7 --- /dev/null +++ b/packages/app/src/components/sidebar-resize-handle-layout.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { resolveSidebarResizeHandleGeometry } from "./sidebar-resize-handle-layout"; + +const MIN_TOUCH_TARGET = 24; + +describe("resolveSidebarResizeHandleGeometry", () => { + it("keeps the touch hit area inside the sidebar so native hit-testing reaches it", () => { + const geometry = resolveSidebarResizeHandleGeometry(false); + + expect(geometry.edgeOffset).toBeGreaterThanOrEqual(0); + expect(geometry.width).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); + expect(geometry.height ?? 0).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); + }); + + it("straddles the border for a fine pointer", () => { + const geometry = resolveSidebarResizeHandleGeometry(true); + + expect(geometry.edgeOffset).toBe(-geometry.width / 2); + expect(geometry.height).toBeNull(); + }); +}); diff --git a/packages/app/src/components/sidebar-resize-handle-layout.ts b/packages/app/src/components/sidebar-resize-handle-layout.ts new file mode 100644 index 00000000000..c4742beaedd --- /dev/null +++ b/packages/app/src/components/sidebar-resize-handle-layout.ts @@ -0,0 +1,57 @@ +/** + * Geometry for the sidebar resize handle. + * + * A mouse can grab a hairline, so the pointer variant straddles the sidebar + * border — half the strip hangs outside the sidebar's own bounds, which is + * fine because web hit-testing does not clip to the parent box. + * + * Native hit-testing does clip: iOS `hitTest:` returns nil for points outside a + * view's bounds and Gesture Handler's Android traversal only recurses into + * children whose ancestors contain the point. A strip hanging outside the + * sidebar is dead on touch, and what remains is far too small for a finger. + * So the touch variant sits fully inside the sidebar and is sized for a thumb. + */ + +export const POINTER_HANDLE_WIDTH = 10; +export const TOUCH_HANDLE_WIDTH = 24; +export const TOUCH_HANDLE_HEIGHT = 88; + +/** + * A finger resting on the grip drifts before it commits. Requiring horizontal + * intent before the pan activates, and failing it once the drift is mostly + * vertical, leaves the sidebar's own scrolling reachable through the grip. + * A mouse press moves less than either threshold, so pointer drags are + * unaffected. + */ +export const SIDEBAR_RESIZE_ACTIVATION_OFFSET = 6; +export const SIDEBAR_RESIZE_FAIL_OFFSET = 12; + +export type SidebarResizeEdge = "left" | "right"; + +export interface SidebarResizeHandleGeometry { + /** + * Distance from the sidebar edge to the near side of the hit area. Negative + * means the hit area overhangs the sidebar, which only touch forbids. + */ + edgeOffset: number; + width: number; + /** `null` stretches the hit area to the sidebar's full height. */ + height: number | null; +} + +export function resolveSidebarResizeHandleGeometry( + finePointer: boolean, +): SidebarResizeHandleGeometry { + if (finePointer) { + return { + edgeOffset: -POINTER_HANDLE_WIDTH / 2, + width: POINTER_HANDLE_WIDTH, + height: null, + }; + } + return { + edgeOffset: 0, + width: TOUCH_HANDLE_WIDTH, + height: TOUCH_HANDLE_HEIGHT, + }; +} diff --git a/packages/app/src/components/sidebar-resize-handle.tsx b/packages/app/src/components/sidebar-resize-handle.tsx index 617d9076bc2..d842f0f0088 100644 --- a/packages/app/src/components/sidebar-resize-handle.tsx +++ b/packages/app/src/components/sidebar-resize-handle.tsx @@ -2,11 +2,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { GestureDetector, type GestureType } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; +import { + resolveSidebarResizeHandleGeometry, + type SidebarResizeEdge, +} from "@/components/sidebar-resize-handle-layout"; import { isWeb } from "@/constants/platform"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; interface SidebarResizeHandleProps { - edge: "left" | "right"; + edge: SidebarResizeEdge; gesture: GestureType; + pressed: boolean; testID: string; } @@ -18,13 +24,29 @@ const webResizeCursorStyle = isWeb } as object) : null; -export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { +function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) { + return edge === "left" ? { left: edgeOffset } : { right: edgeOffset }; +} + +export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) { + const finePointer = useHasFinePointer(); + + if (finePointer) { + return ; + } + return ; +} + +function PointerResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { const [highlighted, setHighlighted] = useState(false); const highlightTimerRef = useRef | null>(null); - const hitAreaStyle = - edge === "left" - ? [styles.hitArea, styles.leftEdge, webResizeCursorStyle] - : [styles.hitArea, styles.rightEdge, webResizeCursorStyle]; + const geometry = resolveSidebarResizeHandleGeometry(true); + const hitAreaStyle = [ + styles.hitArea, + { width: geometry.width }, + edgeOffsetStyle(edge, geometry.edgeOffset), + webResizeCursorStyle, + ]; const cancelHighlightTimer = useCallback(() => { if (highlightTimerRef.current === null) return; @@ -63,20 +85,49 @@ export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHand ); } +function TouchResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) { + const geometry = resolveSidebarResizeHandleGeometry(false); + // `box-none` keeps the full-height column out of hit-testing so only the + // grab target steals taps from the rows behind it. + const layerStyle = [ + styles.touchLayer, + { width: geometry.width }, + edgeOffsetStyle(edge, geometry.edgeOffset), + ]; + const targetStyle = [ + styles.touchTarget, + { width: geometry.width, height: geometry.height ?? undefined }, + ]; + const gripStyle = [ + styles.grip, + edge === "left" ? styles.leftEdgeGrip : styles.rightEdgeGrip, + pressed ? styles.visibleGrip : styles.hiddenGrip, + ]; + + return ( + + + + + + + + ); +} + const styles = StyleSheet.create((theme) => ({ hitArea: { position: "absolute", top: 0, bottom: 0, - width: 10, zIndex: 10, }, - leftEdge: { - left: -5, - }, - rightEdge: { - right: -5, - }, highlight: { position: "absolute", top: 0, @@ -86,4 +137,36 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.foreground, opacity: 0.25, }, + touchLayer: { + position: "absolute", + top: 0, + bottom: 0, + zIndex: 10, + alignItems: "center", + justifyContent: "center", + }, + touchTarget: { + alignItems: "center", + justifyContent: "center", + }, + grip: { + width: 4, + height: 36, + borderRadius: 2, + backgroundColor: theme.colors.foreground, + }, + hiddenGrip: { + opacity: 0, + }, + visibleGrip: { + opacity: 0.3, + }, + leftEdgeGrip: { + alignSelf: "flex-start", + marginLeft: theme.spacing[0.5], + }, + rightEdgeGrip: { + alignSelf: "flex-end", + marginRight: theme.spacing[0.5], + }, })); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 0314e54dd2d..ae8bf827773 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot, type Root } from "react-dom/client"; import React from "react"; import type { ReactElement } from "react"; +import { createProjectViewKey } from "@/projects/workspace-structure"; vi.hoisted(() => { (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; @@ -38,9 +39,11 @@ 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 { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { defaultHostAppearance } from "@/hosts/appearance"; vi.mock("@react-native-async-storage/async-storage", () => ({ default: { @@ -144,6 +147,7 @@ function makeHost(): HostProfile { return { serverId: SERVER_ID, label: "Render Count Host", + appearance: defaultHostAppearance(), lifecycle: {}, connections: [], preferredConnectionId: null, @@ -164,9 +168,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], @@ -197,7 +199,7 @@ function ProjectHeaderProbe({ project: SidebarProjectEntry; counts: RenderCounts; }): null { - incrementRecord(counts.headers, project.projectKey); + incrementRecord(counts.headers, project.viewKey); return null; } @@ -233,7 +235,7 @@ function ProjectActiveProbe({ activeSelection?.serverId === serverId && project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId); void isActive; - incrementRecord(counts.projectSelection, project.projectKey); + incrementRecord(counts.projectSelection, project.viewKey); return null; } @@ -261,7 +263,7 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { return ( <> {projects.map((project) => ( -
+
{project.workspaces.map((entry) => ( @@ -454,8 +456,8 @@ describe("sidebar workspace render isolation", () => { expect(counts.frame).toBe(1); expect(counts.projectSelection).toEqual({ - "project-a": 1, - "project-b": 1, + [createProjectViewKey({ kind: "equivalence", projectKey: "project-a" })]: 1, + [createProjectViewKey({ kind: "equivalence", projectKey: "project-b" })]: 1, }); expect(counts.rowSelection).toEqual({ "a-main": 1, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9acba693e2f..68de9d73df3 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1,15 +1,15 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Text, Pressable, - ActivityIndicator, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, + type StyleProp, type ViewStyle, } from "react-native"; import { useMutation } from "@tanstack/react-query"; -import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, @@ -21,6 +21,8 @@ import { type ReactElement, type MutableRefObject, type Ref, + type ComponentProps, + type PropsWithChildren, } from "react"; import { useTranslation } from "react-i18next"; import { router, usePathname, type Href } from "expo-router"; @@ -31,13 +33,11 @@ import { } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; 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 { DiffStat } from "@/components/diff-stat"; import { - CircleAlert, - ChevronDown, - ChevronRight, ExternalLink, GitPullRequest, Settings, @@ -57,7 +57,7 @@ import { import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useProjectIcons } from "@/projects/icons"; import { buildNewWorkspaceRoute, buildProjectSettingsRoute, @@ -65,6 +65,7 @@ import { } from "@/utils/host-routes"; import { shouldShowSidebarHostLabels, + useSidebarProjectStatusBucket, type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarWorkspacePlacement, @@ -72,26 +73,32 @@ import { import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; -import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + useContextMenu, +} from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; -import { SyncedLoader } from "@/components/synced-loader"; +import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual"; import { useToast } from "@/contexts/toast-context"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; -import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; -import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu"; +import { + SidebarWorkspaceContextMenu, + SidebarWorkspaceMenu, +} from "@/components/sidebar/sidebar-workspace-menu"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; @@ -100,11 +107,19 @@ import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, SidebarWorkspaceShortcutBadge, + resolveTrailingActionVisibility, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; +import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility"; +import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row"; +import { + SidebarWorkspaceTrailingContent, + useSidebarWorkspaceTrailing, +} from "@/components/sidebar/workspace-trailing"; import { Button } from "@/components/ui/button"; +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"; @@ -114,7 +129,8 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, - resolveSidebarProjectIconTarget, + resolveSidebarProjectIconTargets, + resolveSidebarProjectLocalPath, type SidebarProjectHostTarget, } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; @@ -131,21 +147,20 @@ import { getIsElectron, } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; +import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; +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"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; -const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; +const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey; const WORKSPACE_STATUS_DOT_WIDTH = 14; -const DEFAULT_STATUS_DOT_SIZE = 7; -const EMPHASIZED_STATUS_DOT_SIZE = 9; -const DEFAULT_STATUS_DOT_OFFSET = 0; -const EMPHASIZED_STATUS_DOT_OFFSET = -1; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); -const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedSyncedLoader = withUnistyles(SyncedLoader); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedPlus = withUnistyles(Plus); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); @@ -158,22 +173,13 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const redColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.red[500], -}); -const amberColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.amber[500], + color: theme.colors.statusDanger, }); const greenColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.green[500], + color: theme.colors.statusSuccess, }); const purpleColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.purple[500], -}); -const syncedLoaderColorMapping = (theme: Theme) => ({ - color: - theme.colorScheme === "light" - ? theme.colors.palette.amber[700] - : theme.colors.palette.amber[500], + color: theme.colors.statusMerged, }); function getPrIconUniMapping(state: PrHint["state"]) { @@ -231,9 +237,8 @@ interface SidebarWorkspaceListProps { pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; collapsedProjectKeys: ReadonlySet; - onToggleProjectCollapsed: (projectKey: string) => void; + onToggleProjectCollapsed: (projectViewKey: string) => void; shortcutIndexByWorkspaceKey: Map; groupMode: "project" | "status"; isRefreshing?: boolean; @@ -246,13 +251,14 @@ interface SidebarWorkspaceListProps { listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; + dragGestureHostPresented?: boolean; } interface ProjectHeaderRowProps { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; - workspace: SidebarWorkspaceEntry | null; + statusBucket: SidebarStateBucket | null; selected?: boolean; chevron: "expand" | "collapse" | null; onPress: () => void; @@ -273,7 +279,9 @@ interface ProjectHeaderRowProps { interface WorkspaceRowInnerProps { workspace: SidebarWorkspaceEntry; - subtitle?: string | null; + hostBadge?: HostBadgeModel | null; + leadingProjectName?: string | null; + leadingProjectIconDataUri?: string | null; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; @@ -298,7 +306,7 @@ interface WorkspaceRowInnerProps { reserveIdleStatusIndicatorSpace?: boolean; } -export function PrBadge({ hint }: { hint: PrHint }) { +export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp }) { const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); @@ -317,6 +325,18 @@ export function PrBadge({ hint }: { hint: PrHint }) { const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); + // Callers that place the badge in a list of icon+text rows pass that row's layout in, so the + // icon and text land on the same rails as their neighbors instead of on the badge's tighter + // inline spacing. + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + prBadgeStyles.badge, + style, + pressed && prBadgeStyles.badgePressed, + ], + [style], + ); + const textStyle = isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text; @@ -335,7 +355,7 @@ export function PrBadge({ hint }: { hint: PrHint }) { onPress={handlePress} onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} - style={prBadgePressableStyle} + style={pressableStyle} > {isHovered ? ( @@ -343,17 +363,12 @@ export function PrBadge({ hint }: { hint: PrHint }) { )} - {presentation.numberPrefix} {hint.number} ); } -function prBadgePressableStyle({ pressed }: PressableStateCallbackType) { - return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]; -} - function projectKebabStyle({ hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) { @@ -362,18 +377,21 @@ function projectKebabStyle({ function getProjectWorkspaceRowStyle({ isDragging, + isPressed, selected, isHovered, }: { isDragging: boolean; + isPressed: boolean; selected: boolean; isHovered: boolean; }) { return [ styles.workspaceRow, - isDragging && styles.workspaceRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, + isDragging && styles.workspaceRowDragging, + isPressed && styles.workspaceRowPressed, ]; } @@ -399,93 +417,12 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ }, })); -function StatusDotOverlay({ - dotColorStyle, - size, - offset, -}: { - dotColorStyle: ViewStyle; - size: number; - offset: number; -}) { - const overlayStyle = useMemo( - () => [ - styles.statusDotOverlay, - dotColorStyle, - { - width: size, - height: size, - right: offset, - bottom: offset, - }, - ], - [dotColorStyle, size, offset], - ); - return ; -} - -function ProjectLeadingVisual({ - displayName, - iconDataUri, - workspace, - projectKey, - chevron = null, - showChevron = false, - isArchiving = false, -}: { - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry | null; - projectKey: string; - chevron?: "expand" | "collapse" | null; - showChevron?: boolean; - isArchiving?: boolean; -}) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); - const activeWorkspace = workspace; - const shouldShowWorkspaceStatus = - activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done"); - const shouldShowSyncedLoader = activeWorkspace - ? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket }) - : false; - - if (showChevron && chevron !== null) { - return ( - - - - ); - } - - if (!shouldShowWorkspaceStatus || !activeWorkspace) { - return ( - - - - ); - } - - return ( - - ); -} - function ProjectRowTrailingActions({ - project, + projectViewKey, displayName, worktreeTarget, + settingsTarget, + projectPath, isHovered, isMobileBreakpoint, isProjectActive, @@ -493,9 +430,11 @@ function ProjectRowTrailingActions({ onRemoveProject, removeProjectStatus, }: { - project: SidebarProjectEntry; + projectViewKey: string; displayName: string; worktreeTarget: SidebarProjectHostTarget | null; + settingsTarget: { serverId: string; projectId: string } | null; + projectPath: string; isHovered: boolean; isMobileBreakpoint: boolean; isProjectActive: boolean; @@ -512,7 +451,7 @@ function ProjectRowTrailingActions({ onPress={onBeginWorkspaceSetup} visible={actionsVisible} showShortcutHint={isProjectActive} - testID={`sidebar-project-new-worktree-${project.projectKey}`} + testID={`sidebar-project-new-worktree-${projectViewKey}`} /> ) : null} {onRemoveProject ? ( @@ -521,8 +460,9 @@ function ProjectRowTrailingActions({ pointerEvents={actionsVisible ? "auto" : "none"} > @@ -548,12 +488,70 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { } function ProjectKebabMenu({ - projectKey, + projectViewKey, + settingsTarget, projectPath, onRemoveProject, removeProjectStatus, }: { - projectKey: string; + projectViewKey: string; + settingsTarget: { serverId: string; projectId: string } | null; + projectPath: string; + onRemoveProject: () => void; + removeProjectStatus: "idle" | "pending" | "success"; +}) { + const { t } = useTranslation(); + return ( + + + {renderKebabTriggerIcon} + + + + + + ); +} + +type ProjectMenuSurface = "context" | "dropdown"; + +function ProjectMenuItem({ + surface, + children, + ...props +}: PropsWithChildren< + Omit, "children"> & { surface: ProjectMenuSurface } +>) { + if (surface === "context") { + return {children}; + } + return {children}; +} + +function ProjectMenuItems({ + surface, + projectViewKey, + settingsTarget, + projectPath, + onRemoveProject, + removeProjectStatus, +}: { + surface: ProjectMenuSurface; + projectViewKey: string; + settingsTarget: { serverId: string; projectId: string } | null; projectPath: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; @@ -561,13 +559,9 @@ function ProjectKebabMenu({ const { t } = useTranslation(); const toast = useToast(); const handleOpenProjectSettings = useCallback(() => { - if (projectKey.trim().length === 0) return; - router.navigate(buildProjectSettingsRoute(projectKey)); - }, [projectKey]); - const canOpenProjectSettings = projectKey.trim().length > 0; - // Desktop-only: open a second window that lands on this project via the same - // open-project flow as a CLI launch. The project stays visible here too — no - // ownership, no move. + if (!settingsTarget) return; + router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId)); + }, [settingsTarget]); const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0; const handleOpenInNewWindow = useCallback(() => { const trimmedPath = projectPath.trim(); @@ -579,52 +573,51 @@ function ProjectKebabMenu({ toast.error(t("sidebar.project.actions.openNewWindowFailed")); }); }, [projectPath, t, toast]); + return ( - - - {renderKebabTriggerIcon} - - - {canOpenProjectSettings ? ( - - {t("sidebar.project.actions.openSettings")} - - ) : null} - {canOpenInNewWindow ? ( - - {t("sidebar.project.actions.openNewWindow")} - - ) : null} - + {settingsTarget ? ( + - {t("sidebar.project.actions.remove")} - - - + {t("sidebar.project.actions.openSettings")} + + ) : null} + {canOpenInNewWindow ? ( + + {t("sidebar.project.actions.openNewWindow")} + + ) : null} + + + {t("sidebar.project.actions.remove")} + + ); } function WorkspaceRowRightGroup({ workspace, + backdrop, isHovered, isTouchPlatform, isCreating, @@ -643,6 +636,7 @@ function WorkspaceRowRightGroup({ onTogglePin, }: { workspace: SidebarWorkspaceEntry; + backdrop: SidebarSurfaceBackdrop; isHovered: boolean; isTouchPlatform: boolean; isCreating: boolean; @@ -660,32 +654,43 @@ function WorkspaceRowRightGroup({ isPinned?: boolean; onTogglePin?: () => void; }) { + const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath; const { t } = useTranslation(); + const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; - const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); - const showKebabInSlot = showKebab && !showShortcut; - const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); + const { + showTrailing, + showKebab: showKebabInSlot, + showScrim, + renderSlot, + reserveSlotWidth, + } = resolveTrailingActionVisibility({ + workspace, + trailing, + hasArchiveAction: Boolean(onArchive), + isHovered, + isTouchPlatform, + showShortcut, + }); + const kebab = useOpenKebabMenuVisibility(showKebabInSlot); return ( <> {isCreating ? ( {t("sidebar.workspace.status.creating")} ) : null} - {shouldRenderActionSlot ? ( - - - {workspace.diffStat ? ( - - ) : null} + {renderSlot ? ( + + + - + {onArchive ? ( ) : null} @@ -707,103 +713,6 @@ function WorkspaceRowRightGroup({ ); } -function ProjectIcon({ - iconDataUri, - placeholderInitial, - projectKey, -}: { - iconDataUri: string | null; - placeholderInitial: string; - projectKey: string; -}) { - return ( - - ); -} - -function ProjectLeadingVisualStatus({ - iconDataUri, - placeholderInitial, - projectKey, - isArchiving, - shouldShowSyncedLoader, - activeWorkspace, -}: { - iconDataUri: string | null; - placeholderInitial: string; - projectKey: string; - isArchiving: boolean; - shouldShowSyncedLoader: boolean; - activeWorkspace: SidebarWorkspaceEntry; -}) { - if (isArchiving) { - return ( - - - - ); - } - - if (shouldShowSyncedLoader) { - return ( - - - - ); - } - - if (activeWorkspace.statusBucket === "needs_input") { - return ( - - - - ); - } - - const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket); - const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket) - ? EMPHASIZED_STATUS_DOT_SIZE - : DEFAULT_STATUS_DOT_SIZE; - const statusDotOffset = - statusDotSize === EMPHASIZED_STATUS_DOT_SIZE - ? EMPHASIZED_STATUS_DOT_OFFSET - : DEFAULT_STATUS_DOT_OFFSET; - - return ( - - - {dotColorStyle ? ( - - ) : null} - - ); -} - -function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { - if (chevron === null) { - return null; - } - if (chevron === "collapse") { - return ; - } - return ; -} - function NewWorktreeButton({ displayName, onPress, @@ -855,7 +764,7 @@ function NewWorktreeButton({ > {({ hovered, pressed }) => loading ? ( - + ) : ( [ styles.newWorkspaceGhostRow, - (Boolean(hovered) || pressed) && styles.newWorkspaceGhostRowHovered, + hovered && !pressed && styles.newWorkspaceGhostRowHovered, + pressed && styles.newWorkspaceGhostRowPressed, ], [], ); @@ -921,7 +831,7 @@ function NewWorkspaceGhostRow({ })} onPress={handlePress} style={rowStyle} - testID={`sidebar-project-new-workspace-row-${project.projectKey}`} + testID={`sidebar-project-new-workspace-row-${project.viewKey}`} > {({ hovered, pressed }) => ( <> @@ -951,7 +861,7 @@ function ProjectHeaderRow({ project, displayName, iconDataUri, - workspace, + statusBucket, selected = false, chevron, onPress, @@ -970,7 +880,12 @@ function ProjectHeaderRow({ dragHandleProps, }: ProjectHeaderRowProps) { const [isHovered, setIsHovered] = useState(false); + const [isPressed, setIsPressed] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); const isMobileBreakpoint = useIsCompactFormFactor(); + const localDaemonServerId = useLocalDaemonServerId(); + const projectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId); + const settingsTarget = project.hosts[0] ?? null; const handleBeginWorkspaceSetup = useCallback(() => { if (!worktreeTarget) { return; @@ -981,10 +896,10 @@ function ProjectHeaderRow({ serverId: worktreeTarget.serverId, sourceDirectory: worktreeTarget.iconWorkingDir, displayName, - projectId: project.projectKey, + projectId: worktreeTarget.projectId, }) as Href, ); - }, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]); + }, [displayName, onWorkspacePress, worktreeTarget]); const interaction = useLongPressDragInteraction({ drag, menuController, @@ -1004,8 +919,25 @@ function ProjectHeaderRow({ onPress(); }, [interaction.didLongPressRef, onPress]); - const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerEnter = useCallback(() => { + if (!contextMenuOpen) setIsHovered(true); + }, [contextMenuOpen]); const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const handleContextMenuOpenChange = useCallback((open: boolean) => { + setContextMenuOpen(open); + if (open) setIsHovered(false); + }, []); + const handleProjectPressIn = useCallback( + (event: GestureResponderEvent) => { + setIsPressed(true); + interaction.handlePressIn(event); + }, + [interaction], + ); + const handleProjectPressOut = useCallback(() => { + setIsPressed(false); + interaction.handlePressOut(); + }, [interaction]); const projectRowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ @@ -1024,8 +956,9 @@ function ProjectHeaderRow({ ); - if (menuController) { + if (!onRemoveProject) { return ( - {rowChildren} - + ); } return ( - } - onPointerEnter={handlePointerEnter} - onPointerLeave={handlePointerLeave} - > - + } + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} > - {rowChildren} - - + + {rowChildren} + + + + + + ); } function WorkspaceRowInner({ workspace, - subtitle, + hostBadge, + leadingProjectName, + leadingProjectIconDataUri, selected, shortcutNumber, showShortcutBadge, @@ -1130,6 +1085,7 @@ function WorkspaceRowInner({ reserveIdleStatusIndicatorSpace = true, }: WorkspaceRowInnerProps) { const _isCompact = useIsCompactFormFactor(); + const [isPressed, setIsPressed] = useState(false); const isTouchPlatform = platformIsNative; const interaction = useLongPressDragInteraction({ drag, @@ -1149,26 +1105,32 @@ function WorkspaceRowInner({ } onPress(); }, [interaction.didLongPressRef, onPress]); + const handleWorkspacePressIn = useCallback( + (event: GestureResponderEvent) => { + setIsPressed(true); + interaction.handlePressIn(event); + }, + [interaction], + ); + const handleWorkspacePressOut = useCallback(() => { + setIsPressed(false); + interaction.handlePressOut(); + }, [interaction]); const accessibilityState = useMemo(() => ({ selected }), [selected]); return ( - {({ isHovered, hoverHandlers }) => { + {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const isDesktop = !isTouchPlatform; - const showScriptsIcon = isDesktop && workspace.hasRunningScripts; - const hasRunningService = workspace.scripts.some( - (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", - ); - let scriptIconKind: "service" | "command" | null = null; - if (showScriptsIcon) { - scriptIconKind = hasRunningService ? "service" : "command"; - } + const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, + isPressed, selected, isHovered, }); + const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered }); return ( - - + ); }} @@ -1229,7 +1213,9 @@ function WorkspaceRowInner({ function WorkspaceRowWithMenu({ workspace, - subtitle, + hostBadge, + leadingProjectName, + leadingProjectIconDataUri, selected, shortcutNumber, showShortcutBadge, @@ -1244,7 +1230,9 @@ function WorkspaceRowWithMenu({ isCreating = false, }: { workspace: SidebarWorkspaceEntry; - subtitle?: string | null; + hostBadge?: HostBadgeModel | null; + leadingProjectName?: string | null; + leadingProjectIconDataUri?: string | null; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; @@ -1368,22 +1356,13 @@ function WorkspaceRowWithMenu({ }, }); - useKeyboardActionHandler({ - handlerId: `workspace-pin-${workspace.workspaceKey}`, - actions: ["workspace.pin"], - enabled: selected && canPin, - priority: 0, - handle: () => { - onTogglePin?.(); - return true; - }, - }); - return ( <> void; @@ -1566,7 +1557,9 @@ function WorkspaceRow({ return ( ; parentGestureRef?: MutableRefObject; - onToggleCollapsed: (projectKey: string) => void; + onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; + dragGestureHostPresented?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; - hostLabelByServerId: ReadonlyMap; - showHostLabels: boolean; + hostBadgeByServerId: ReadonlyMap; supportsMultiplicityByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; @@ -1650,6 +1643,13 @@ function ProjectBlock({ [collapsed, project, supportsMultiplicityByServerId], ); + // Collapsed rows hide their workspace rows, so the project row carries the most urgent + // status among them; expanded rows leave the signal to the child rows themselves. + const aggregateStatusBucket = useSidebarProjectStatusBucket({ + workspaces: project.workspaces, + enabled: collapsed, + }); + const active = isProjectSelectedByRoute({ selection: activeWorkspaceSelection, project, @@ -1669,9 +1669,7 @@ function ProjectBlock({ { - onWorkspaceReorder(project.projectKey, workspaces); + onWorkspaceReorder(project.viewKey, workspaces); }, - [onWorkspaceReorder, project.projectKey], + [onWorkspaceReorder, project.viewKey], ); const toast = useToast(); @@ -1749,7 +1746,6 @@ function ProjectBlock({ setIsRemovingProject(true); const readiness = getCurrentProjectRemoveReadiness({ - projectKey: project.projectKey, hosts: project.hosts, }); if (readiness.kind === "needs_host_update") { @@ -1759,7 +1755,6 @@ function ProjectBlock({ } void removeProjectFromHosts({ - projectKey: project.projectKey, targets: readiness.targets, getClient: (serverId) => getHostRuntimeStore().getClient(serverId), }) @@ -1782,11 +1777,11 @@ function ProjectBlock({ setIsRemovingProject(false); }); })(); - }, [isRemovingProject, displayName, t, toast, project.projectKey, project.hosts]); + }, [isRemovingProject, displayName, t, toast, project.hosts]); const handleToggleCollapsed = useCallback(() => { - onToggleCollapsed(project.projectKey); - }, [onToggleCollapsed, project.projectKey]); + onToggleCollapsed(project.viewKey); + }, [onToggleCollapsed, project.viewKey]); let projectChildren = null; if (!collapsed) { @@ -1794,7 +1789,7 @@ function ProjectBlock({ projectChildren = ( <> {canToggleWorkspaces ? ( ) : null} @@ -1828,12 +1824,16 @@ function ProjectBlock({ } return ( - + { - const labels = new Map(); - for (const host of hosts) { - labels.set(host.serverId, host.label?.trim() || host.serverId); - } - return labels; - }, [hosts]); + const rowItems = useSidebarRowItems(); + // Host badge visibility is a lattice, not three competing switches: this gate is the global + // "off", `shouldShowSidebarHostLabels` is the automatic "there is only one host so it says + // nothing", and each host's own `badgeDisplay` decides name vs icon vs hidden. Turning the + // item off here removes the badge everywhere; leaving it on defers to the per-host setting. + const hostBadgeByServerId = useHostBadges({ + enabled: rowItems.host && shouldShowSidebarHostLabels(projects), + }); const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); const onToggleWorkspacePin = useSidebarWorkspacePinController(); - const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]); + const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder); + const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder); + const handlePinnedWorkspaceReorder = useCallback( + (reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); + const currentOrder = getPinnedWorkspaceOrder(); + if ( + !hasVisibleOrderChanged({ + currentOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + return; + } + + setPinnedWorkspaceOrder( + mergeWithRemainder({ + currentOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }), + ); + }, + [getPinnedWorkspaceOrder, setPinnedWorkspaceOrder], + ); + // Status mode drops the project grouping, so its rows carry their own project + // icon. Project mode fetches the same icons inside ProjectModeList for its + // project headers, so only the active mode requests them. + const statusProjectIconTargets = useMemo( + () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), + [groupMode, projects], + ); + const statusProjectIconByProjectViewKey = useProjectIcons({ + projects: statusProjectIconTargets, + }); const content = groupMode === "status" ? ( @@ -1956,14 +1990,16 @@ export function SidebarWorkspaceList({ statusGroups={statusGroups} pinnedGroups={pinnedGroups} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} + projectIconByProjectViewKey={statusProjectIconByProjectViewKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onWorkspacePress={onWorkspacePress} - hostLabelByServerId={hostLabelByServerId} - showHostLabels={showHostLabels} + hostBadgeByServerId={hostBadgeByServerId} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} + onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder} listHeaderComponent={listHeaderComponent} + parentGestureRef={parentGestureRef} + dragGestureHostPresented={dragGestureHostPresented} /> ) : ( ); @@ -1994,45 +2031,56 @@ function SidebarStatusModeWrapper({ statusGroups, pinnedGroups, workspaceEntriesByKey, - projectNamesByKey, + projectIconByProjectViewKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, - hostLabelByServerId, - showHostLabels, + hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, + onPinnedWorkspaceReorder, listHeaderComponent, + parentGestureRef, + dragGestureHostPresented, }: { statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; - hostLabelByServerId: ReadonlyMap; - showHostLabels: boolean; + hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; + onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; listHeaderComponent?: ReactElement | null; + parentGestureRef?: MutableRefObject; + dragGestureHostPresented?: boolean; }) { const showShortcutBadges = useShowShortcutBadges(); + const pinnedWorkspaces = useMemo( + () => + pinnedGroups.pinnedChats.flatMap((workspace) => { + const entry = workspaceEntriesByKey.get(workspace.workspaceKey); + return entry ? [entry] : []; + }), + [pinnedGroups.pinnedChats, workspaceEntriesByKey], + ); return ( { - const entry = workspaceEntriesByKey.get(workspace.workspaceKey); - return entry ? [entry] : []; - })} - projectNamesByKey={projectNamesByKey} + pinnedWorkspaces={pinnedWorkspaces} + projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} - hostLabelByServerId={hostLabelByServerId} - showHostLabels={showHostLabels} + hostBadgeByServerId={hostBadgeByServerId} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} + onPinnedWorkspaceReorder={onPinnedWorkspaceReorder} listHeaderComponent={listHeaderComponent} + parentGestureRef={parentGestureRef} + dragGestureHostPresented={dragGestureHostPresented} /> ); } @@ -2049,22 +2097,20 @@ function ProjectModeList({ listFooterComponent, listHeaderComponent, parentGestureRef, + dragGestureHostPresented, pathname, - hostLabelByServerId, - showHostLabels, + hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, -}: Omit< - SidebarWorkspaceListProps, - "statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" -> & { + onPinnedWorkspaceReorder, +}: Omit & { pathname: string; - hostLabelByServerId: ReadonlyMap; - showHostLabels: boolean; + hostBadgeByServerId: ReadonlyMap; supportsMultiplicityByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; + onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; }) { const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const { t } = useTranslation(); @@ -2096,14 +2142,7 @@ function ProjectModeList({ canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, } = useLimitedSidebarGroup(pinnedChats); - const projectIconTargets = useMemo( - () => - projects.flatMap((project) => { - const target = resolveSidebarProjectIconTarget(project); - return target ? [{ projectKey: project.projectKey, ...target }] : []; - }), - [projects], - ); + const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]); const nativeScrollGestureProps = useMemo( () => parentGestureRef @@ -2118,7 +2157,7 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectKey = useProjectIconDataByProjectKey({ + const projectIconByProjectViewKey = useProjectIcons({ projects: projectIconTargets, }); @@ -2170,7 +2209,7 @@ function ProjectModeList({ const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { - const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); + const reorderedProjectKeys = reorderedProjects.map((project) => project.viewKey); const currentProjectOrder = getProjectOrder(); if ( !hasVisibleOrderChanged({ @@ -2192,9 +2231,9 @@ function ProjectModeList({ ); const handleWorkspaceReorder = useCallback( - (projectKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(projectKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, @@ -2205,7 +2244,7 @@ function ProjectModeList({ } setWorkspaceOrder( - projectKey, + projectViewKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, @@ -2252,12 +2291,12 @@ function ProjectModeList({ ) => { return ( { - // A hoisted chat loses its project context, so surface the project name (plus - // host when the sidebar spans multiple hosts) as the subtitle. - const hostLabel = showHostLabels - ? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId) - : null; + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => { return ( ); }, [ activeWorkspaceSelection, creatingWorkspaceIds, - hostLabelByServerId, + hostBadgeByServerId, onWorkspacePress, selectionEnabled, shortcutIndexByWorkspaceKey, - showHostLabels, showShortcutBadges, supportsPinningByServerId, onToggleWorkspacePin, + projectIconByProjectViewKey, workspaceEntriesByKey, ], ); @@ -2355,7 +2400,20 @@ function ProjectModeList({ {pinnedCollapsed ? null : ( <> - {visiblePinnedChats.map(renderPinnedChat)} + {canTogglePinnedChats ? ( )} @@ -2443,17 +2502,30 @@ const styles = StyleSheet.create((theme) => ({ pinnedSection: { marginBottom: theme.spacing[1], }, - projectBlock: { - marginBottom: theme.spacing[1], + // Three times the gap a row keeps from its neighbour, so the break between two groups reads as + // a break rather than as one more row of pitch. Kept equal to `statusGroupBlockExpanded` — the + // two groupings are the same list under a different heading and must not breathe differently. + // + // Padding on the block rather than margin, and only while it has children: the gap belongs to + // the rows underneath the header, so a collapsed project gives it back and a column of collapsed + // headers closes up to the pitch of a list instead of staying spaced for content that is gone. + projectBlockExpanded: { + paddingBottom: theme.spacing[3], }, workspaceListContainer: {}, + // Kept in step with `workspaceRow` above. It stands in a project's list where a workspace row + // would be, so it takes that row's geometry and both of its fills. + // + // The one departure is the extra left padding: it only ever renders under its project header, so + // 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: 32, - marginLeft: theme.spacing[6], - marginRight: theme.spacing[1], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + minHeight: 36, + marginBottom: theme.spacing[0.5], + paddingVertical: theme.spacing[2], + paddingLeft: theme.spacing[4], + paddingRight: theme.spacing[3], + borderRadius: theme.borderRadius.lg, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], @@ -2462,9 +2534,14 @@ const styles = StyleSheet.create((theme) => ({ newWorkspaceGhostRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, + newWorkspaceGhostRowPressed: { + backgroundColor: theme.colors.surface2, + }, + // 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.sm, - height: theme.iconSize.sm, + width: theme.iconSize.md, + height: theme.iconSize.md, alignItems: "center", justifyContent: "center", flexShrink: 0, @@ -2544,31 +2621,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, - projectIcon: { - width: "100%", - height: "100%", - borderRadius: theme.borderRadius.sm, - }, - projectLeadingVisualSlot: { - position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, - projectIconFallback: { - width: "100%", - height: "100%", - borderRadius: theme.borderRadius.sm, - alignItems: "center", - justifyContent: "center", - }, - projectIconFallbackText: { - fontSize: 9, - }, projectTitle: { - color: theme.colors.foreground, + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: "400", minWidth: 0, @@ -2609,6 +2663,9 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: 2, flexShrink: 0, + // MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls, + // but pull their painted edge through the unused view-box space onto the row rail. + marginRight: -6, }, projectKebabButton: { width: 24, @@ -2648,7 +2705,7 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceRow: { minHeight: 36, - marginBottom: theme.spacing[1], + marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], paddingRight: theme.spacing[3], @@ -2708,15 +2765,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - statusDotOverlay: { - position: "absolute", - right: DEFAULT_STATUS_DOT_OFFSET, - bottom: DEFAULT_STATUS_DOT_OFFSET, - width: DEFAULT_STATUS_DOT_SIZE, - height: DEFAULT_STATUS_DOT_SIZE, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - }, workspaceArchivingOverlay: { ...StyleSheet.absoluteFillObject, borderRadius: theme.borderRadius.lg, @@ -2758,35 +2806,12 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, flexShrink: 0, }, - statusDotNeedsInput: { - backgroundColor: theme.colors.palette.amber[500], - borderColor: theme.colors.surface0, - }, - statusDotFailed: { - backgroundColor: theme.colors.palette.red[500], - borderColor: theme.colors.surface0, - }, - statusDotRunning: { - backgroundColor: theme.colors.palette.blue[500], - borderColor: theme.colors.surface0, + kebabButton: { + padding: 2, + borderRadius: 4, + marginLeft: 2, }, - statusDotAttention: { - backgroundColor: theme.colors.palette.green[500], - borderColor: theme.colors.surface0, + kebabButtonHovered: { + backgroundColor: theme.colors.surface2, }, })); - -function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null { - switch (bucket) { - case "needs_input": - return styles.statusDotNeedsInput; - case "failed": - return styles.statusDotFailed; - case "running": - return styles.statusDotRunning; - case "attention": - return styles.statusDotAttention; - case "done": - return null; - } -} diff --git a/packages/app/src/components/sidebar/display-preferences/checks-display.ts b/packages/app/src/components/sidebar/display-preferences/checks-display.ts new file mode 100644 index 00000000000..ae34b1e1cbf --- /dev/null +++ b/packages/app/src/components/sidebar/display-preferences/checks-display.ts @@ -0,0 +1,27 @@ +/** + * How much of a workspace's CI a sidebar row spends space on. + * + * Not one of the boolean row items, because the icon on its own is a real third answer rather + * than half of "off": the state stays on the row for a glance, and a passing workspace stops + * paying a word for it on every line. A boolean cannot say that, and widening the row-items + * record to hold it would make three settings carry a shape only one of them uses. + * + * Pure on purpose, like `row-items.ts`: `hooks/use-settings/storage.ts` validates the persisted + * value through `parseSidebarChecksDisplay` rather than growing its own copy of the value list. + */ + +export const SIDEBAR_CHECKS_DISPLAYS = ["iconAndText", "icon", "none"] as const; + +export type SidebarChecksDisplay = (typeof SIDEBAR_CHECKS_DISPLAYS)[number]; + +export const DEFAULT_SIDEBAR_CHECKS_DISPLAY: SidebarChecksDisplay = "iconAndText"; + +/** Null for anything that isn't one of the three, so callers can tell "absent" from "chosen". */ +export function parseSidebarChecksDisplay(value: unknown): SidebarChecksDisplay | null { + if (typeof value !== "string") { + return null; + } + return (SIDEBAR_CHECKS_DISPLAYS as readonly string[]).includes(value) + ? (value as SidebarChecksDisplay) + : null; +} diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx new file mode 100644 index 00000000000..b0ba9ba5250 --- /dev/null +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -0,0 +1,460 @@ +import { useCallback, useMemo, type ComponentType, type ReactElement, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + Captions, + CircleCheck, + CircleDashed, + Clock, + Diff, + EyeOff, + Folder, + GitBranch, + GitPullRequest, + Globe, + Server, + Settings2, + Type, +} from "lucide-react-native"; +import { + MenuItem, + MenuRoot, + MenuSeparator, + MenuSubTrigger, + MenuSurface, + MenuTrigger, + type MenuPageDefinition, +} from "@/components/ui/menu"; +import { HostStatusDot } from "@/components/host-status-dot"; +import { isWeb } from "@/constants/platform"; +import { useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import type { WorkspaceTitleSource } from "@/hooks/use-settings"; +import { SIDEBAR_CHECKS_DISPLAYS, type SidebarChecksDisplay } from "./checks-display"; +import { useSidebarDisplayPreferences, type SidebarTrailingChoice } from "./model"; +import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items"; + +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const ThemedSettings2 = withUnistyles(Settings2); +/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */ +const ThemedCircleCheck = withUnistyles(CircleCheck); + +/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */ +const OPTION_ICON_SIZE = 14; +const MENU_WIDTH = 232; + +type OptionIcon = ComponentType<{ + size: number; + uniProps: (theme: Theme) => { color: string }; +}>; + +// Options carry icons; the root rows deliberately do not. The root is four labels with their +// current values, and a column of icons there would be decoration competing with the values. +const GROUPING_ICONS: Record = { + project: withUnistyles(Folder), + status: withUnistyles(CircleDashed), +}; + +const TITLE_SOURCE_ICONS: Record = { + title: withUnistyles(Type), + branch: withUnistyles(GitBranch), +}; + +// The same marks these things carry on the workspace row itself, so the menu and the row it +// configures name each item the same way twice. +const ROW_ITEM_ICONS: Record = { + host: withUnistyles(Server), + changeRequest: withUnistyles(GitPullRequest), + services: withUnistyles(Globe), +}; + +// These mark how much of the row an option spends, not what CI is, so they are the shapes each +// answer produces: a glyph with words beside it, the glyph on its own, nothing. +const CHECKS_DISPLAY_ICONS: Record = { + iconAndText: withUnistyles(Captions), + icon: ThemedCircleCheck, + none: withUnistyles(EyeOff), +}; + +const TRAILING_ICONS: Record = { + diff: withUnistyles(Diff), + timestamp: withUnistyles(Clock), +}; + +const GROUPING_MODES: readonly SidebarGroupMode[] = ["project", "status"]; +const TITLE_SOURCES: readonly WorkspaceTitleSource[] = ["title", "branch"]; +const TRAILING_CHOICES: readonly SidebarTrailingChoice[] = ["diff", "timestamp"]; + +const GROUPING_LABEL_KEYS: Record = { + project: "sidebar.display.grouping.project", + status: "sidebar.display.grouping.status", +}; + +const TITLE_SOURCE_LABEL_KEYS: Record = { + title: "sidebar.display.titleSource.title", + branch: "sidebar.display.titleSource.branch", +}; + +const ROW_ITEM_LABEL_KEYS: Record = { + host: "sidebar.display.show.host", + changeRequest: "sidebar.display.show.changeRequest", + services: "sidebar.display.show.services", +}; + +const CHECKS_DISPLAY_LABEL_KEYS: Record = { + iconAndText: "sidebar.display.checks.iconAndText", + icon: "sidebar.display.checks.icon", + none: "sidebar.display.checks.none", +}; + +const TRAILING_LABEL_KEYS: Record = { + diff: "sidebar.display.show.diff", + timestamp: "sidebar.display.show.timestamp", +}; + +/** + * What the sidebar shows and how it is arranged. + * + * The root is one row per decision with its current value; the options live a level down. The + * shape is deliberate — every option of every decision on one surface is what this menu used to + * be, and it grew a row for each host on top of that. + */ +export function SidebarDisplayPreferencesMenu(): ReactElement { + const { t } = useTranslation(); + const preferences = useSidebarDisplayPreferences(); + const hosts = useHosts(); + + const triggerStyle = useCallback( + ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.trigger, + hovered && styles.triggerHovered, + ], + [], + ); + + const showHostFilter = hosts.length > 1; + + const pages = useMemo(() => { + const definitions: MenuPageDefinition[] = [ + { + id: "grouping", + title: t("sidebar.display.grouping.label"), + content: ( + + ), + }, + { + id: "titleSource", + title: t("sidebar.display.titleSource.label"), + content: ( + + ), + }, + { + id: "show", + title: t("sidebar.display.show.label"), + content: , + }, + { + id: "checks", + title: t("sidebar.display.show.checks"), + content: ( + + ), + }, + ]; + + if (showHostFilter) { + definitions.push({ + id: "hostFilter", + title: t("sidebar.display.hostFilter.label"), + content: , + }); + } + return definitions; + }, [t, preferences, hosts, showHostFilter]); + + return ( + + + + + + + {t("sidebar.display.grouping.label")} + + + {t("sidebar.display.titleSource.label")} + + + {t("sidebar.display.show.label")} + + {showHostFilter ? ( + <> + + {/* A filtered sidebar looks like workspaces went missing, so the branch says so + from the root rather than making you open it to find out. */} + 0} + testID="sidebar-display-host-filter" + > + {t("sidebar.display.hostFilter.label")} + + + ) : null} + + + ); +} + +type Preferences = ReturnType; + +/** One option row: its mark on the left, its label, and a check when it is the chosen one. */ +function OptionItem({ + value, + icon: Icon, + label, + selected, + closeOnSelect = true, + onSelect, + testID, +}: { + value: Value; + icon: OptionIcon; + label: string; + selected: boolean; + closeOnSelect?: boolean; + onSelect: (value: Value) => void; + testID: string; +}): ReactElement { + const handleSelect = useCallback(() => onSelect(value), [onSelect, value]); + const leading = useMemo( + () => , + [Icon], + ); + return ( + + {label} + + ); +} + +/** A page of mutually exclusive options — pick one and the menu closes. */ +function OptionList({ + values, + icons, + labelKeys, + selectedValue, + onSelect, + testIDPrefix, +}: { + values: readonly Value[]; + icons: Record; + labelKeys: Record; + selectedValue: Value; + onSelect: (value: Value) => void; + testIDPrefix: string; +}): ReactNode { + const { t } = useTranslation(); + return values.map((value) => ( + + )); +} + +/** + * Two groups, split by the separator. Above it, what a row may say about a workspace — each one + * independent. Below it, the one thing the slot to the right of the title holds, so picking the + * one already showing empties the slot and gives the width back to the title. + * + * CI is the one item above the separator with three answers rather than two, so it opens a page + * instead of ticking, and it goes last: a row that navigates does not belong in the middle of a + * column you are running down with your eyes ticking things on and off. + */ +function ShowPage({ preferences }: { preferences: Preferences }): ReactElement { + const { t } = useTranslation(); + return ( + <> + {SIDEBAR_ROW_ITEMS.map((item) => ( + + ))} + + + {TRAILING_CHOICES.map((choice) => ( + + ))} + + ); +} + +/** + * No value on the row, only the chevron. The three answers are all long enough that the value + * fills the row and stops reading as an answer sitting at the right edge — it turns into a second + * line of label. The chevron alone says there is a decision in here, and the page says what it is. + */ +function ChecksSubTrigger(): ReactElement { + const { t } = useTranslation(); + const leading = useMemo( + () => , + [], + ); + return ( + + {t("sidebar.display.show.checks")} + + ); +} + +function HostFilterPage({ + preferences, + hosts, +}: { + preferences: Preferences; + hosts: ReturnType; +}): ReactElement { + const { t } = useTranslation(); + return ( + <> + + {t("sidebar.display.hostFilter.all")} + + {hosts.map((host) => ( + + ))} + + ); +} + +/** The one option row whose mark is live state rather than an icon. */ +function HostFilterItem({ + serverId, + label, + selected, + onToggle, +}: { + serverId: string; + label: string; + selected: boolean; + onToggle: (serverId: string) => void; +}): ReactElement { + const handleSelect = useCallback(() => onToggle(serverId), [onToggle, serverId]); + const leading = useMemo( + () => ( + + + + ), + [serverId], + ); + + return ( + + {label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + }, + triggerHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, +})); diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts new file mode 100644 index 00000000000..a56077487d3 --- /dev/null +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from "react"; +import { + useAppSettings, + type SidebarWorkspaceTrailing, + type WorkspaceTitleSource, +} from "@/hooks/use-settings"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display"; +import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items"; + +/** The trailing slot holds one thing, so these are a choice rather than toggles. */ +export type SidebarTrailingChoice = Exclude; + +export interface SidebarDisplayPreferences { + grouping: SidebarGroupMode; + setGrouping: (mode: SidebarGroupMode) => void; + titleSource: WorkspaceTitleSource; + setTitleSource: (source: WorkspaceTitleSource) => void; + rowItems: SidebarRowItems; + toggleRowItem: (item: SidebarRowItem) => void; + checksDisplay: SidebarChecksDisplay; + setChecksDisplay: (display: SidebarChecksDisplay) => void; + trailing: SidebarWorkspaceTrailing; + /** Picking the choice that is already showing clears the slot. */ + toggleTrailing: (choice: SidebarTrailingChoice) => void; + hostFilters: readonly string[]; + toggleHostFilter: (serverId: string) => void; + clearHostFilters: () => void; +} + +/** + * Every decision the sidebar's display-preferences menu can make, behind one interface. + * + * Grouping and host filters live in a local zustand store while the title source and row items + * are synced app settings — a split that exists for good reasons (a filter is transient view + * state; a preference follows you) and that the menu has no business knowing about. Callers ask + * this for a value and set it; where it lands is this module's problem. + */ +export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { + const grouping = useSidebarViewStore((state) => state.groupMode); + const setGrouping = useSidebarViewStore((state) => state.setGroupMode); + const hostFilters = useSidebarViewStore((state) => state.hostFilters); + const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter); + const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters); + + const { + settings: { + workspaceTitleSource, + sidebarWorkspaceTrailing, + sidebarRowItems, + sidebarChecksDisplay, + }, + updateSettings, + } = useAppSettings(); + + const setTitleSource = useCallback( + (source: WorkspaceTitleSource) => { + void updateSettings({ workspaceTitleSource: source }); + }, + [updateSettings], + ); + + const toggleRowItem = useCallback( + (item: SidebarRowItem) => { + void updateSettings({ + sidebarRowItems: { ...sidebarRowItems, [item]: !sidebarRowItems[item] }, + }); + }, + [updateSettings, sidebarRowItems], + ); + + const setChecksDisplay = useCallback( + (display: SidebarChecksDisplay) => { + void updateSettings({ sidebarChecksDisplay: display }); + }, + [updateSettings], + ); + + const toggleTrailing = useCallback( + (choice: SidebarTrailingChoice) => { + void updateSettings({ + sidebarWorkspaceTrailing: sidebarWorkspaceTrailing === choice ? "none" : choice, + }); + }, + [updateSettings, sidebarWorkspaceTrailing], + ); + + return useMemo( + () => ({ + grouping, + setGrouping, + titleSource: workspaceTitleSource, + setTitleSource, + rowItems: sidebarRowItems, + toggleRowItem, + checksDisplay: sidebarChecksDisplay, + setChecksDisplay, + trailing: sidebarWorkspaceTrailing, + toggleTrailing, + hostFilters, + toggleHostFilter, + clearHostFilters, + }), + [ + grouping, + setGrouping, + workspaceTitleSource, + setTitleSource, + sidebarRowItems, + toggleRowItem, + sidebarChecksDisplay, + setChecksDisplay, + sidebarWorkspaceTrailing, + toggleTrailing, + hostFilters, + toggleHostFilter, + clearHostFilters, + ], + ); +} + +/** + * Just the row items, for the row renderers. They re-render per workspace, so they subscribe to + * the one field they use rather than to every preference in the menu. + */ +export function useSidebarRowItems(): SidebarRowItems { + const { + settings: { sidebarRowItems }, + } = useAppSettings(); + return sidebarRowItems ?? DEFAULT_SIDEBAR_ROW_ITEMS; +} + +/** + * Everything the line under a workspace title needs to know, in one read. The two settings are + * answered together by `selectMetaRowItems`, so asking for them separately would only mean two + * subscriptions per row for one decision. + */ +export function useSidebarMetaPreferences(): { + rowItems: SidebarRowItems; + checksDisplay: SidebarChecksDisplay; +} { + const { + settings: { sidebarRowItems, sidebarChecksDisplay }, + } = useAppSettings(); + return useMemo( + () => ({ + rowItems: sidebarRowItems ?? DEFAULT_SIDEBAR_ROW_ITEMS, + checksDisplay: sidebarChecksDisplay ?? DEFAULT_SIDEBAR_CHECKS_DISPLAY, + }), + [sidebarRowItems, sidebarChecksDisplay], + ); +} diff --git a/packages/app/src/components/sidebar/display-preferences/row-items.test.ts b/packages/app/src/components/sidebar/display-preferences/row-items.test.ts new file mode 100644 index 00000000000..c83c4a705fb --- /dev/null +++ b/packages/app/src/components/sidebar/display-preferences/row-items.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SIDEBAR_ROW_ITEMS, + isChecksHiddenByLegacyRowItem, + parseSidebarRowItems, + SIDEBAR_ROW_ITEMS, +} from "./row-items"; + +describe("parseSidebarRowItems", () => { + it("shows everything by default", () => { + for (const item of SIDEBAR_ROW_ITEMS) { + expect(DEFAULT_SIDEBAR_ROW_ITEMS[item]).toBe(true); + } + }); + + it("applies stored overrides", () => { + expect(parseSidebarRowItems({ services: false })).toEqual({ + ...DEFAULT_SIDEBAR_ROW_ITEMS, + services: false, + }); + }); + + it("leaves items absent from storage at their default", () => { + // A newly added item must ship visible rather than inheriting "missing means off". + expect(parseSidebarRowItems({ host: false })).toEqual({ + ...DEFAULT_SIDEBAR_ROW_ITEMS, + host: false, + }); + }); + + it("ignores unknown keys and non-boolean values", () => { + expect( + parseSidebarRowItems({ checks: false, nonsense: false, services: null, host: false }), + ).toEqual({ ...DEFAULT_SIDEBAR_ROW_ITEMS, host: false }); + }); + + it.each([[null], [undefined], ["diff"], [42], [["services"]]])( + "falls back to defaults for %s", + (value) => { + expect(parseSidebarRowItems(value)).toEqual(DEFAULT_SIDEBAR_ROW_ITEMS); + }, + ); + + it("carries a switched-off scripts item over to services", () => { + // COMPAT(sidebarRowItemsScripts): the item narrowed from scripts to services in v0.3.0. + expect(parseSidebarRowItems({ scripts: false })).toEqual({ + ...DEFAULT_SIDEBAR_ROW_ITEMS, + services: false, + }); + }); + + it("lets a stored services value win over the scripts item it replaced", () => { + expect(parseSidebarRowItems({ scripts: false, services: true })).toEqual( + DEFAULT_SIDEBAR_ROW_ITEMS, + ); + }); + + it("does not hand back the shared default object", () => { + const parsed = parseSidebarRowItems({ services: false }); + expect(parsed).not.toBe(DEFAULT_SIDEBAR_ROW_ITEMS); + expect(DEFAULT_SIDEBAR_ROW_ITEMS.services).toBe(true); + }); +}); + +describe("isChecksHiddenByLegacyRowItem", () => { + it("reports the one stored shape that means checks were switched off", () => { + expect(isChecksHiddenByLegacyRowItem({ checks: false })).toBe(true); + }); + + it.each([[{ checks: true }], [{ services: false }], [{}], [null], ["checks"], [["checks"]]])( + "leaves %s to the new setting's default", + (value) => { + expect(isChecksHiddenByLegacyRowItem(value)).toBe(false); + }, + ); +}); diff --git a/packages/app/src/components/sidebar/display-preferences/row-items.ts b/packages/app/src/components/sidebar/display-preferences/row-items.ts new file mode 100644 index 00000000000..3ef1d04aa99 --- /dev/null +++ b/packages/app/src/components/sidebar/display-preferences/row-items.ts @@ -0,0 +1,66 @@ +/** + * Which facts a sidebar workspace row is allowed to show about a workspace. + * + * Each item is independent — this is not a mode. Turning one off removes it from every row and + * gives the space back to the title. + * + * CI is not here: it has three answers rather than two, so it is its own setting in + * `checks-display.ts`. + * + * Pure on purpose: `hooks/use-settings/storage.ts` validates the persisted value through + * `parseSidebarRowItems` rather than growing its own checks, so this module stays the one place + * that knows what an item is. + */ + +export const SIDEBAR_ROW_ITEMS = ["host", "changeRequest", "services"] as const; + +export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number]; + +export type SidebarRowItems = Record; + +/** + * Everything on by default. A new item ships visible and users turn it off, which is also why + * the persisted shape is a record of overrides rather than a list of enabled items: an absent + * key has to mean "default", not "off". + */ +export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = { + host: true, + changeRequest: true, + services: true, +}; + +/** + * COMPAT(sidebarRowItemsChecks): CI was a boolean row item until v0.3.0, when it became a + * display mode of its own. A stored `false` is someone who switched checks off, and they must + * not come back as icon-and-text on upgrade. Remove after 2027-08-05. + */ +export function isChecksHiddenByLegacyRowItem(value: unknown): boolean { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + return (value as Record).checks === false; +} + +function isSidebarRowItem(value: string): value is SidebarRowItem { + return (SIDEBAR_ROW_ITEMS as readonly string[]).includes(value); +} + +export function parseSidebarRowItems(value: unknown): SidebarRowItems { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return DEFAULT_SIDEBAR_ROW_ITEMS; + } + + const stored = value as Record; + const result = { ...DEFAULT_SIDEBAR_ROW_ITEMS }; + // COMPAT(sidebarRowItemsScripts): the item was called "scripts" until v0.3.0, when it narrowed + // to services only. A stored `false` is someone who switched it off. Remove after 2027-08-05. + if (stored.scripts === false) { + result.services = false; + } + for (const [key, entry] of Object.entries(stored)) { + if (isSidebarRowItem(key) && typeof entry === "boolean") { + result[key] = entry; + } + } + return result; +} diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx new file mode 100644 index 00000000000..68dbb19afc2 --- /dev/null +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -0,0 +1,317 @@ +import { ActivityIndicator, View, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +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 type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { + getProjectStatusBadgeContent, + type ProjectStatusBadgeContent, + type ProjectStatusBadgeDotBucket, +} from "@/utils/project-status-badge-content"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { + STATUS_INDICATOR_ALERT_SIZE, + STATUS_INDICATOR_FILLED_DOT_SIZE, +} from "@/utils/status-indicator-geometry"; +import { StatusRing } from "@/components/status-ring"; +import { getStatusRingOffset } from "@/components/status-ring/geometry"; +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; + +// Every surfaced status shares one badge shell, so the badge never changes size or position +// between states. Only the thing inside it changes. +const STATUS_BADGE_SIZE = 12; +// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge: +// centered on the corner it reads as hanging off the icon rather than sitting on it, and the +// sidebar row has no padding there to absorb the overhang. +const STATUS_BADGE_OFFSET = -4; +// Both glyphs must be EVEN. A centered glyph of size N sits at a (12 - N) / 2 offset — fractional +// for odd N, which the browser snaps to a device-pixel boundary and renders visibly off-center (an +// odd size measured 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps). +// Even sizes divide the shell into whole pixels and land dead center with no correction. +// +// 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 ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedCircleAlert = withUnistyles(CircleAlert); + +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const needsInputColorMapping = (theme: Theme) => ({ + color: theme.colors.surface0, + fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, +}); + +/** + * Leading slot of a sidebar project row: chevron on hover, archive spinner while removing, + * otherwise the project icon carrying the project's aggregate workspace status. + */ +export function ProjectLeadingVisual({ + displayName, + iconDataUri, + statusBucket, + projectViewKey, + backdrop, + chevron = null, + showChevron = false, + isArchiving = false, +}: { + displayName: string; + iconDataUri: string | null; + /** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */ + statusBucket: SidebarStateBucket | null; + projectViewKey: string; + /** The row's current background, so the status badge can knock out of it. */ + backdrop: SidebarSurfaceBackdrop; + chevron?: "expand" | "collapse" | null; + showChevron?: boolean; + isArchiving?: boolean; +}) { + if (showChevron && chevron !== null) { + return ( + + + + ); + } + + if (isArchiving) { + return ( + + + + ); + } + + return ( + + ); +} + +// The project icon (the lettered box) is what marks a row as a *project* rather than a +// workspace, so it always stays and status annotates it instead of replacing it. Every +// surfaced bucket lands in the identical corner badge — an amber alert glyph for needs_input, +// a colored disc for the rest, nothing for done — so the badge reads as one fixed shell and +// only its contents change. +export function ProjectStatusIndicator({ + iconDataUri, + displayName, + projectViewKey, + statusBucket, + backdrop, + loading = false, + testID, +}: { + iconDataUri: string | null; + displayName: string; + projectViewKey: string; + statusBucket: SidebarStateBucket | null; + /** The row's current background, so the status badge can knock out of it. */ + backdrop: SidebarSurfaceBackdrop; + loading?: boolean; + testID?: string; +}) { + const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName) + .charAt(0) + .toUpperCase(); + // A row that's still starting up and a row that's working are both "busy", and at this size + // there's no room to draw the difference — so `loading` just resolves to the running bucket + // and they share one badge. + const badgeBucket = loading ? "running" : statusBucket; + const badgeContent = getProjectStatusBadgeContent(badgeBucket); + + return ( + + + + {badgeContent === null || badgeBucket === null ? null : ( + + )} + + + ); +} + +function ProjectStatusBadge({ + content, + statusBucket, + backdrop, +}: { + content: ProjectStatusBadgeContent; + statusBucket: SidebarStateBucket; + backdrop: SidebarSurfaceBackdrop; +}) { + // 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. + if (content.kind === "dot" && content.bucket === "running") { + return ( + + + + ); + } + return ( + + {content.kind === "alert" ? ( + + ) : ( + + )} + + ); +} + +function getStatusBadgeBackdropStyle(backdrop: SidebarSurfaceBackdrop): ViewStyle { + switch (backdrop) { + case "surfaceSidebar": + return styles.statusBadgeOnSidebar; + case "surfaceSidebarHover": + return styles.statusBadgeOnSidebarHover; + case "surface2": + return styles.statusBadgeOnSurface2; + } +} + +function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) { + return ; +} + +function ProjectIcon({ + iconDataUri, + placeholderInitial, + projectViewKey, +}: { + iconDataUri: string | null; + placeholderInitial: string; + projectViewKey: string; +}) { + return ( + + ); +} + +function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { + if (chevron === null) { + return null; + } + if (chevron === "collapse") { + return ; + } + return ; +} + +function getStatusDotColorStyle(bucket: ProjectStatusBadgeDotBucket): ViewStyle { + if (bucket === "failed") return styles.statusDotFailed; + if (bucket === "running") return styles.statusDotRunning; + return styles.statusDotAttention; +} + +const styles = StyleSheet.create((theme) => { + // Dot statuses sit *inside* the shell rather than replacing it, so they carry the same ring + // the alert does. The geometry is shared here and baked into each colored variant so the + // style prop stays a single stable object; the colors come from the one bucket-to-color map + // so this badge can't drift from the status dots everywhere else. + const statusDot = (bucket: ProjectStatusBadgeDotBucket) => + ({ + width: STATUS_INDICATOR_FILLED_DOT_SIZE, + height: STATUS_INDICATOR_FILLED_DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined, + }) as const; + + 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. + projectLeadingVisualSlot: { + width: theme.iconSize.md, + height: LEADING_SLOT_HEIGHT, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + // Anchors the corner badge to the icon rather than to the taller slot. + projectIconBox: { + position: "relative", + width: theme.iconSize.md, + height: theme.iconSize.md, + }, + projectIconFallbackText: { + fontSize: 9, + }, + // The shell the alert and dot statuses share. It straddles the icon's bottom-right corner + // (half in, half out) so the lettered project box stays readable. The shell is a knockout: + // it is filled with the colour of the row behind it, which is what makes the ring around the + // dot read as a gap in the icon rather than as a white halo drawn on top of it. + statusBadge: { + position: "absolute", + right: STATUS_BADGE_OFFSET, + bottom: STATUS_BADGE_OFFSET, + width: STATUS_BADGE_SIZE, + height: STATUS_BADGE_SIZE, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + // Same corner as the shell, re-centred for the wider ring. + statusRingAnchor: { + position: "absolute", + right: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE), + bottom: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE), + }, + statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar }, + statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover }, + statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 }, + statusDotRunning: statusDot("running"), + statusDotFailed: statusDot("failed"), + statusDotAttention: statusDot("attention"), + }; +}); diff --git a/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx b/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx deleted file mode 100644 index bac8319cc41..00000000000 --- a/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Settings2 } from "lucide-react-native"; -import type { Theme } from "@/styles/theme"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { HostStatusDot } from "@/components/host-status-dot"; -import { isWeb as platformIsWeb } from "@/constants/platform"; -import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings"; -import { useHosts } from "@/runtime/host-runtime"; -import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; - -const ThemedSettings2 = withUnistyles(Settings2); -const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [ - { value: "project", label: "Project" }, - { value: "status", label: "Status" }, -]; - -const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [ - { value: "title", label: "Title" }, - { value: "branch", label: "Branch name" }, -]; - -interface DisplayPreferenceOption { - value: Value; - label: string; -} - -export function SidebarDisplayPreferencesMenu() { - const groupMode = useSidebarViewStore((state) => state.groupMode); - const hostFilters = useSidebarViewStore((state) => state.hostFilters); - const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); - const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter); - const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters); - const hosts = useHosts(); - const { - settings: { workspaceTitleSource }, - updateSettings, - } = useAppSettings(); - - const handleSelectMode = useCallback( - (mode: SidebarGroupMode) => { - setGroupMode(mode); - }, - [setGroupMode], - ); - - const handleWorkspaceTitleSourceSelect = useCallback( - (source: WorkspaceTitleSource) => { - void updateSettings({ workspaceTitleSource: source }); - }, - [updateSettings], - ); - - const triggerStyle = useCallback( - ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.trigger, - hovered && styles.triggerHovered, - ], - [], - ); - - const showHostFilter = hosts.length > 1; - const allHostsSelected = hostFilters.length === 0; - - return ( - - - - - - - Group by - - {GROUP_MODE_ITEMS.map((item) => ( - - ))} - {showHostFilter ? ( - <> - - - Filter - - - All hosts - - {hosts.map((host) => ( - - ))} - - ) : null} - - - Workspace title - - {WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => ( - - ))} - - - ); -} - -function DisplayPreferenceMenuItem({ - item, - isSelected, - testIDPrefix, - onSelect, -}: { - item: DisplayPreferenceOption; - isSelected: boolean; - testIDPrefix: string; - onSelect: (value: Value) => void; -}) { - const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]); - return ( - - {item.label} - - ); -} - -function HostFilterItem({ - label, - serverId, - selected, - onToggle, -}: { - label: string; - serverId: string; - selected: boolean; - onToggle: (serverId: string) => void; -}) { - const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]); - const leading = useMemo( - () => ( - - - - ), - [serverId], - ); - - return ( - - {label} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - trigger: { - width: 28, - height: 28, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.md, - }, - triggerHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, - menuHeader: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - menuHeaderLabel: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, - }, - optionLabel: { - fontSize: theme.fontSize.sm, - }, -})); 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 d6b0c6e04fe..5b458ae7178 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -4,6 +4,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na import { ChevronDown, ChevronUp } from "lucide-react-native"; import { StyleSheet, withUnistyles } 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) => ({ @@ -15,13 +16,25 @@ const foregroundColorMapping = (theme: Theme) => ({ const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronUp = withUnistyles(ChevronUp); +/** + * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so + * it takes the workspace row's geometry — height, padding, radius, and both fills — rather than a + * set of its own. Sitting under a column of rows, anything it does differently reads as a mistake + * rather than as a distinction. + * + * `indented` because the two groupings disagree: status rows sit on their header's label rail and + * project rows sit flush, so the caller says which list this row is ending. The indent itself is + * the workspace row's, imported rather than re-derived. + */ export function SidebarGroupToggleRow({ expanded, onPress, + indented = false, testID, }: { expanded: boolean; onPress: () => void; + indented?: boolean; testID: string; }) { const { t } = useTranslation(); @@ -31,9 +44,11 @@ export function SidebarGroupToggleRow({ const rowStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.row, - (Boolean(hovered) || pressed) && styles.rowHovered, + indented && sidebarWorkspaceRowStyles.rowIndented, + hovered && !pressed && styles.rowHovered, + pressed && styles.rowPressed, ], - [], + [indented], ); return ( @@ -69,13 +84,14 @@ 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: 32, - marginLeft: theme.spacing[6], - marginRight: theme.spacing[1], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + minHeight: 36, + marginBottom: theme.spacing[0.5], + paddingVertical: theme.spacing[2], + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[3], + borderRadius: theme.borderRadius.lg, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], @@ -84,9 +100,14 @@ const styles = StyleSheet.create((theme) => ({ rowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, + rowPressed: { + backgroundColor: theme.colors.surface2, + }, + // 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.sm, - height: theme.iconSize.sm, + width: theme.iconSize.md, + height: theme.iconSize.md, alignItems: "center", justifyContent: "center", flexShrink: 0, diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 6168d5d394c..9d7acbaf995 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -15,9 +15,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; 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"; @@ -69,20 +68,20 @@ function HostVersionHint({ host }: { host: HostProfile }) { return ( - {host.label} {version} + {host.label} ); } export function SidebarHelpMenu() { const { t } = useTranslation(); - const isCompactLayout = useIsCompactFormFactor(); + const shortcutsAvailable = useKeyboardShortcutsAvailable(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); const [open, setOpen] = useState(false); - const showKeyboardShortcuts = !isNative && !isCompactLayout; const version = formatVersionWithPrefix(resolveAppVersion()); const hosts = useHosts(); @@ -128,7 +127,7 @@ export function SidebarHelpMenu() { {t("sidebar.help.sectionHelp")} - {showKeyboardShortcuts ? ( + {shortcutsAvailable ? ( - - {t("sidebar.help.version", { version })} + + {t("sidebar.help.appName")} {hosts.map((host) => ( diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index 101cc55728b..f9f25bb5587 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -9,6 +9,7 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { buildSidebarProjection } from "./sidebar-projection"; @@ -18,7 +19,7 @@ interface SidebarModel extends SidebarWorkspacesListResult { statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; collapsedProjectKeys: ReadonlySet; - toggleProjectCollapsed: (projectKey: string) => void; + toggleProjectCollapsed: (projectViewKey: string) => void; shortcutModel: SidebarShortcutModel; } @@ -41,6 +42,7 @@ export function SidebarModelProvider({ (state) => state.collapsedStatusGroupKeys, ); const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); + const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder); const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleProjectCollapsed, ); @@ -58,8 +60,9 @@ export function SidebarModelProvider({ buildSidebarProjection({ projects: list.projects, pinnedKeys, + pinnedWorkspaceOrder, workspaceEntriesByKey: projectionWorkspaceEntriesByKey, - projectNamesByKey: list.projectNamesByKey, + projectNamesByViewKey: list.projectNamesByViewKey, groupMode, pinnedCollapsed, collapsedProjectKeys, @@ -69,10 +72,11 @@ export function SidebarModelProvider({ collapsedProjectKeys, collapsedStatusGroupKeys, groupMode, - list.projectNamesByKey, + list.projectNamesByViewKey, list.projects, pinnedCollapsed, pinnedKeys, + pinnedWorkspaceOrder, projectionWorkspaceEntriesByKey, ], ); diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index a7ab9648601..5eb82a9a833 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -11,7 +11,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu workspaceKey: `srv:${id}`, serverId: "srv", workspaceId: id, - projectKey: "project", + projectViewKey: "project", projectName: "Project", projectKind: "git", workspaceKind: "worktree", @@ -19,6 +19,8 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu }; const entry: SidebarWorkspaceEntry = { ...placement, + workspaceDirectory: "", + workspaceDirectoryLabel: "", title: null, currentBranch: null, statusBucket, @@ -36,15 +38,16 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { return { - projectKey: "project", + viewKey: "project", projectName: "Project", projectKind: "git", iconWorkingDir: "/repo", hosts: [ { serverId: "srv", + projectId: "project", iconWorkingDir: "/repo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces, @@ -63,11 +66,12 @@ function projectionInput(options?: { pinnedWorkspaceKeys: [pinned.placement.workspaceKey], pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" }, }, + pinnedWorkspaceOrder: [], workspaceEntriesByKey: new Map([ [pinned.entry.workspaceKey, pinned.entry], [unpinned.entry.workspaceKey, unpinned.entry], ]), - projectNamesByKey: new Map([["project", "Project"]]), + projectNamesByViewKey: new Map([["project", "Project"]]), groupMode: options?.groupMode ?? ("project" as const), pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index 641fb7e2a91..d6c0c0e3a58 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -24,8 +24,9 @@ export interface SidebarProjection { export function buildSidebarProjection(input: { projects: SidebarProjectEntry[]; pinnedKeys: PinnedSidebarKeys; + pinnedWorkspaceOrder: string[]; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; groupMode: SidebarGroupMode; pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; @@ -34,6 +35,7 @@ export function buildSidebarProjection(input: { const pinnedGroups = splitPinnedSidebarGroups({ projects: input.projects, keys: input.pinnedKeys, + pinnedWorkspaceOrder: input.pinnedWorkspaceOrder, }); const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys); const statusGroups = @@ -42,7 +44,7 @@ export function buildSidebarProjection(input: { Array.from(input.workspaceEntriesByKey.values()).filter( (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), ), - input.projectNamesByKey, + input.projectNamesByViewKey, ) : []; @@ -61,7 +63,7 @@ export function buildSidebarProjection(input: { sections.push( ...pinnedGroups.unpinnedProjects.map((project) => ({ workspaces: project.workspaces, - collapsed: input.collapsedProjectKeys.has(project.projectKey), + collapsed: input.collapsedProjectKeys.has(project.viewKey), })), ); } diff --git a/packages/app/src/components/sidebar/sidebar-row-backdrop.ts b/packages/app/src/components/sidebar/sidebar-row-backdrop.ts new file mode 100644 index 00000000000..1b56d9bd584 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-row-backdrop.ts @@ -0,0 +1,27 @@ +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; + +/** + * Which surface a sidebar row is currently painting, so anything knocking out of it — the status + * badge on a project icon — can match. + * + * This restates the precedence of the row style arrays, which live in three separate renderers + * (`sidebar-workspace-list`, `sidebar-status-list`, `sidebar-workspace-row`). One copy of the rule + * here rather than three ternaries beside three stylesheets: drifting from them is how the badge + * ends up with a halo, and a halo in one grouping mode but not the other is worse than either. + * + */ +export function getSidebarRowBackdrop({ + isDragging = false, + isPressed = false, + selected = false, + isHovered = false, +}: { + isDragging?: boolean; + isPressed?: boolean; + selected?: boolean; + isHovered?: boolean; +}): SidebarSurfaceBackdrop { + if (isDragging || isPressed) return "surface2"; + if (selected || isHovered) return "surfaceSidebarHover"; + return "surfaceSidebar"; +} diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index a7f0d7216b1..a1ca1324e65 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -1,14 +1,32 @@ -import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; +import { + memo, + useCallback, + useMemo, + useState, + type MutableRefObject, + type ReactNode, + type Ref, +} from "react"; import { useTranslation } from "react-i18next"; -import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; +import { + View, + Text, + Pressable, + ScrollView, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; +import type { GestureType } from "react-native-gesture-handler"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; +import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { StyleSheet } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { ChevronDown, @@ -18,7 +36,6 @@ import { CircleDot, CircleX, } from "lucide-react-native"; -import { DiffStat } from "@/components/diff-stat"; import { useToast } from "@/contexts/toast-context"; import { useMutation } from "@tanstack/react-query"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; @@ -35,25 +52,52 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, + resolveTrailingActionVisibility, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, + sidebarWorkspaceRowStyles, } from "@/components/sidebar/sidebar-workspace-row-content"; +import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility"; +import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row"; +import { + SidebarWorkspaceTrailingContent, + useSidebarWorkspaceTrailing, + type SidebarWorkspaceTrailing, +} from "@/components/sidebar/workspace-trailing"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu"; +import { + SidebarWorkspaceContextMenu, + SidebarWorkspaceMenu, +} from "@/components/sidebar/sidebar-workspace-menu"; import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; +import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list"; +import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; +import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; // Themed icon wrappers const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); -const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); -const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); -const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); +// 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) => ({ + color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, +}); +const failedColorMapping = (theme: Theme) => ({ + color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, +}); +const attentionColorMapping = (theme: Theme) => ({ + color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, +}); +const runningColorMapping = (theme: Theme) => ({ + color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, +}); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); @@ -61,32 +105,42 @@ const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleX = withUnistyles(CircleX); +const EMPTY_SHORTCUT_INDEX = new Map(); + +function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string { + return workspace.workspaceKey; +} + interface StatusWorkspaceListProps { groups: StatusGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; - projectNamesByKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; - hostLabelByServerId: ReadonlyMap; - showHostLabels: boolean; + hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; + onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void; listHeaderComponent?: ReactNode; + parentGestureRef?: MutableRefObject; + dragGestureHostPresented?: boolean; } export function SidebarStatusWorkspaceList({ groups, pinnedWorkspaces, - projectNamesByKey, + projectIconByProjectViewKey, shortcutIndexByWorkspaceKey, showShortcutBadges, onWorkspacePress, - hostLabelByServerId, - showHostLabels, + hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, + onPinnedWorkspaceReorder, listHeaderComponent, + parentGestureRef, + dragGestureHostPresented, }: StatusWorkspaceListProps) { const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, @@ -102,7 +156,44 @@ export function SidebarStatusWorkspaceList({ toggleExpanded: togglePinnedWorkspacesExpanded, } = useLimitedSidebarGroup(pinnedWorkspaces); - const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map(); + const statusShortcutIndex = showShortcutBadges + ? shortcutIndexByWorkspaceKey + : EMPTY_SHORTCUT_INDEX; + const renderPinnedWorkspace = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => ( + + ), + [ + hostBadgeByServerId, + onToggleWorkspacePin, + onWorkspacePress, + projectIconByProjectViewKey, + showShortcutBadges, + statusShortcutIndex, + supportsPinningByServerId, + ], + ); const content = ( <> {pinnedWorkspaces.length > 0 ? ( @@ -110,23 +201,18 @@ export function SidebarStatusWorkspaceList({ {pinnedCollapsed ? null : ( <> - {visiblePinnedWorkspaces.map((workspace) => ( - - ))} + {canTogglePinnedWorkspaces ? ( @@ -182,23 +267,21 @@ export function SidebarStatusWorkspaceList({ function StatusGroupList({ groups, collapsedStatusGroupKeys, - projectNamesByKey, + projectIconByProjectViewKey, shortcutIndex, showShortcutBadges, onWorkspacePress, - hostLabelByServerId, - showHostLabels, + hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, }: { groups: StatusGroup[]; collapsedStatusGroupKeys: ReadonlySet; - projectNamesByKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; - hostLabelByServerId: ReadonlyMap; - showHostLabels: boolean; + hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { @@ -209,12 +292,11 @@ function StatusGroupList({ key={group.bucket} group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} - projectNamesByKey={projectNamesByKey} + projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndex={shortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} - hostLabelByServerId={hostLabelByServerId} - showHostLabels={showHostLabels} + hostBadgeByServerId={hostBadgeByServerId} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} /> @@ -226,23 +308,21 @@ function StatusGroupList({ function StatusGroupRows({ group, collapsed, - projectNamesByKey, + projectIconByProjectViewKey, shortcutIndex, showShortcutBadges, onWorkspacePress, - hostLabelByServerId, - showHostLabels, + hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, }: { group: StatusGroup; collapsed: boolean; - projectNamesByKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; - hostLabelByServerId: ReadonlyMap; - showHostLabels: boolean; + hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { @@ -254,7 +334,7 @@ function StatusGroupRows({ } = useLimitedSidebarGroup(group.rows); return ( - + {!collapsed ? ( ) : null} @@ -291,19 +371,26 @@ function StatusGroupRows({ ); } -// Status mode breaks the project grouping, so the row needs the project name to stay -// legible; the host is appended after a middle dot once labels are active. -function buildStatusRowSubtitle({ - projectName, - hostLabel, -}: { +interface StatusRowProjectPresentation { + hostBadge: HostBadgeModel | null; projectName: string; - hostLabel: string | null; -}): string { - if (!hostLabel) { - return projectName; - } - return projectName ? `${projectName} · ${hostLabel}` : hostLabel; + projectIconDataUri: string | null; +} + +function buildStatusRowProjectPresentation({ + workspace, + projectIconByProjectViewKey, + hostBadgeByServerId, +}: { + workspace: SidebarWorkspaceEntry; + projectIconByProjectViewKey: ReadonlyMap; + hostBadgeByServerId: ReadonlyMap; +}): StatusRowProjectPresentation { + return { + hostBadge: hostBadgeByServerId.get(workspace.serverId) ?? null, + projectName: workspace.projectName, + projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null, + }; } function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) { @@ -376,13 +463,13 @@ function StatusGroupLeadingVisual({ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { switch (bucket) { case "needs_input": - return ; + return ; case "failed": - return ; + return ; case "attention": - return ; + return ; case "running": - return ; + return ; case "done": return ; } @@ -390,22 +477,38 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ workspace, - subtitle, + hostBadge, + projectName, + projectIconDataUri, shortcutNumber, showShortcutBadge, canPin, onToggleWorkspacePin, reserveIdleStatusIndicatorSpace = true, + inStatusGroup = true, onWorkspacePress, + drag, + isDragging = false, + dragHandleProps, }: { workspace: SidebarWorkspaceEntry; - subtitle: string; + hostBadge: HostBadgeModel | null; + projectName: string; + projectIconDataUri: string | null; shortcutNumber: number | null; showShortcutBadge: boolean; canPin: boolean; onToggleWorkspacePin: ToggleSidebarWorkspacePin; reserveIdleStatusIndicatorSpace?: boolean; + /** + * Whether the row sits under a status header, which is what it indents from. Pinned rows + * are a flat list under their own header and sit flush. + */ + inStatusGroup?: boolean; onWorkspacePress?: () => void; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { const activeWorkspaceSelection = useActiveWorkspaceSelection(); const selected = @@ -421,38 +524,60 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ return ( ); }); function StatusWorkspaceRowWithMenu({ workspace, - subtitle, + hostBadge, + projectName, + projectIconDataUri, selected, shortcutNumber, showShortcutBadge, canPin, onToggleWorkspacePin, reserveIdleStatusIndicatorSpace = true, + inStatusGroup = true, onPress, + drag, + isDragging = false, + dragHandleProps, }: { workspace: SidebarWorkspaceEntry; - subtitle: string; + hostBadge: HostBadgeModel | null; + projectName: string; + projectIconDataUri: string | null; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; canPin: boolean; onToggleWorkspacePin: ToggleSidebarWorkspacePin; reserveIdleStatusIndicatorSpace?: boolean; + /** + * Whether the row sits under a status header, which is what it indents from. Pinned rows + * are a flat list under their own header and sit flush. + */ + inStatusGroup?: boolean; onPress: () => void; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { const { t } = useTranslation(); const toast = useToast(); @@ -549,22 +674,13 @@ function StatusWorkspaceRowWithMenu({ }, }); - useKeyboardActionHandler({ - handlerId: `workspace-pin-${workspace.workspaceKey}`, - actions: ["workspace.pin"], - enabled: selected && canPin, - priority: 0, - handle: () => { - onTogglePin?.(); - return true; - }, - }); - return ( <> void; reserveIdleStatusIndicatorSpace?: boolean; + /** Pinned rows are flat under their own header; status-group rows indent from theirs. */ + inStatusGroup?: boolean; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; +} + +function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) { + if (props.drag) { + return ; + } + return ; +} + +function DraggableStatusWorkspaceRowInner( + props: StatusWorkspaceRowInnerProps & { drag: () => void }, +) { + const dragInteraction = useLongPressDragInteraction({ + drag: props.drag, + menuController: null, + }); + return ; +} + +function StatusWorkspaceRowInnerContent({ + workspace, + hostBadge, + projectName, + projectIconDataUri, + selected, + shortcutNumber, + showShortcutBadge, + onPress, + isArchiving, + archiveLabel, + archiveStatus = "idle", + archivePendingLabel, + onArchive, + onCopyBranchName, + onCopyPath, + onRename, + onMarkAsRead, + archiveShortcutKeys, + isPinned, + onTogglePin, + reserveIdleStatusIndicatorSpace = true, + inStatusGroup = true, + isDragging = false, + dragHandleProps, + dragInteraction, +}: StatusWorkspaceRowInnerProps & { + dragInteraction?: ReturnType; }) { const isTouchPlatform = platformIsNative; + const [isPressed, setIsPressed] = useState(false); + const trailing = useSidebarWorkspaceTrailing(); + const { + role: _dragRole, + tabIndex: _dragTabIndex, + "aria-roledescription": _dragRoleDescription, + ...dragAttributes + } = dragHandleProps?.attributes ?? {}; const isDesktop = !isTouchPlatform; - const showScriptsIcon = isDesktop && workspace.hasRunningScripts; - const hasRunningService = workspace.scripts.some( - (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", - ); - let scriptIconKind: "service" | "command" | null = null; - if (showScriptsIcon) { - scriptIconKind = hasRunningService ? "service" : "command"; - } + const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; const accessibilityState = useMemo(() => ({ selected }), [selected]); + const didLongPressRef = dragInteraction?.didLongPressRef; + const startDragPress = dragInteraction?.handlePressIn; + const moveDragPress = dragInteraction?.handleTouchMove; + const endDragPress = dragInteraction?.handlePressOut; + const handlePress = useCallback(() => { + if (didLongPressRef?.current) { + didLongPressRef.current = false; + return; + } + onPress(); + }, [didLongPressRef, onPress]); + const handlePressIn = useCallback( + (event: GestureResponderEvent) => { + setIsPressed(true); + startDragPress?.(event); + }, + [startDragPress], + ); + const handlePressOut = useCallback(() => { + setIsPressed(false); + endDragPress?.(); + }, [endDragPress]); return ( - - {({ isHovered, hoverHandlers }) => { + + {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; - const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); - const showKebabInSlot = showKebab && !showShortcut; - const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); - const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered }); + const { + showTrailing, + showKebab: showKebabInSlot, + showScrim, + renderSlot, + reserveSlotWidth, + } = resolveTrailingActionVisibility({ + workspace, + trailing, + hasArchiveAction: Boolean(onArchive), + isHovered, + isTouchPlatform, + showShortcut, + }); + const workspaceRowStyle = getStatusWorkspaceRowStyle({ + isPressed, + selected, + isHovered, + inStatusGroup, + isDragging, + }); + const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered }); return ( - - } + style={styles.workspaceRowContainer} + {...hoverHandlers} + > + - {shouldRenderActionSlot ? ( + {renderSlot ? ( ) : null} - + ); }} @@ -709,8 +940,12 @@ function StatusWorkspaceRowInner({ function StatusWorkspaceActionSlot({ workspace, + backdrop, + trailing, showBase, showKebab, + showScrim, + reserveSlotWidth, isPinned, onTogglePin, onCopyPath, @@ -724,8 +959,12 @@ function StatusWorkspaceActionSlot({ archiveShortcutKeys, }: { workspace: SidebarWorkspaceEntry; + backdrop: SidebarSurfaceBackdrop; + trailing: SidebarWorkspaceTrailing; showBase: boolean; showKebab: boolean; + showScrim: boolean; + reserveSlotWidth: boolean; isPinned?: boolean; onTogglePin?: () => void; onCopyPath?: () => void; @@ -738,19 +977,19 @@ function StatusWorkspaceActionSlot({ archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { + const kebab = useOpenKebabMenuVisibility(showKebab); return ( - + - {workspace.diffStat ? ( - - ) : null} + - - {showKebab && onArchive ? ( + + {kebab.showKebab && onArchive ? ( ({ pinnedSection: { marginBottom: theme.spacing[1], }, - statusGroupBlock: { - marginBottom: theme.spacing[1], + // Matches `projectBlockExpanded` in sidebar-workspace-list.tsx. See the note there. + statusGroupBlockExpanded: { + paddingBottom: theme.spacing[3], }, statusWorkspaceListContainer: {}, statusGroupRow: { @@ -845,7 +1094,7 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, statusGroupTitle: { - color: theme.colors.foreground, + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: "400", minWidth: 0, @@ -856,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceRow: { minHeight: 36, - marginBottom: theme.spacing[1], + marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], paddingRight: theme.spacing[3], @@ -873,6 +1122,14 @@ const styles = StyleSheet.create((theme) => ({ workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, + workspaceRowDragging: { + backgroundColor: theme.colors.surface2, + borderWidth: 1, + borderColor: theme.colors.border, + transform: [{ scale: 1.02 }], + zIndex: 3, + ...theme.shadow.md, + }, sidebarRowSelected: { backgroundColor: theme.colors.surfaceSidebarHover, }, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index e04fc7ab6d6..332b23842ed 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -1,9 +1,12 @@ -import { useMemo } from "react"; +import { useMemo, type ComponentProps, type PropsWithChildren, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; +import { getForgePresentation, normalizeForge } from "@/git/forge"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useAppSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { @@ -12,7 +15,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Shortcut } from "@/components/ui/shortcut"; +import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; +import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title"; +import { + workspaceServiceLabelKey, + type WorkspaceServiceSummary, +} from "@/components/sidebar/workspace-meta-row"; const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -45,7 +60,7 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) { ); } -interface SidebarWorkspaceMenuProps { +export interface SidebarWorkspaceMenuProps { workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; @@ -58,9 +73,39 @@ interface SidebarWorkspaceMenuProps { archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; + openInFileManagerPath?: string | null; + /** + * Lifted so the row that reveals the kebab can keep it mounted while its menu is up. See + * `useOpenKebabMenuVisibility`. + */ + open?: boolean; + onOpenChange?: (open: boolean) => void; } -export function SidebarWorkspaceMenu({ +interface SidebarWorkspaceMenuItemsProps extends Omit< + SidebarWorkspaceMenuProps, + "onArchive" | "open" | "onOpenChange" +> { + onArchive?: () => void; +} + +type MenuSurface = "context" | "dropdown"; + +function WorkspaceMenuItem({ + surface, + children, + ...props +}: PropsWithChildren< + Omit, "children"> & { surface: MenuSurface } +>) { + if (surface === "context") { + return {children}; + } + return {children}; +} + +function SidebarWorkspaceMenuItems({ + surface, workspaceKey, onCopyPath, onCopyBranchName, @@ -73,15 +118,108 @@ export function SidebarWorkspaceMenu({ archiveShortcutKeys, isPinned, onTogglePin, -}: SidebarWorkspaceMenuProps) { + openInFileManagerPath, +}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode { const { t } = useTranslation(); const archiveTrailing = useMemo( - () => (archiveShortcutKeys && !isNative ? : null), + () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); return ( - + <> + {onCopyPath ? ( + + {t("sidebar.workspace.actions.copyPath")} + + ) : null} + {onCopyBranchName ? ( + + {t("sidebar.workspace.actions.copyBranchName")} + + ) : null} + {onRename ? ( + + {t("sidebar.workspace.actions.rename")} + + ) : null} + {onMarkAsRead ? ( + + Mark as read + + ) : null} + {onTogglePin ? ( + + {isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")} + + ) : null} + + {onArchive ? ( + + {archiveLabel ?? t("sidebar.workspace.actions.archive")} + + ) : null} + + ); +} + +export function SidebarWorkspaceMenu({ + workspaceKey, + onCopyPath, + onCopyBranchName, + onRename, + onMarkAsRead, + onArchive, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, + isPinned, + onTogglePin, + openInFileManagerPath, + open, + onOpenChange, +}: SidebarWorkspaceMenuProps) { + const { t } = useTranslation(); + return ( + {renderTriggerIcon} - - {onCopyPath ? ( - - {t("sidebar.workspace.actions.copyPath")} - - ) : null} - {onCopyBranchName ? ( - - {t("sidebar.workspace.actions.copyBranchName")} - - ) : null} - {onRename ? ( - - {t("sidebar.workspace.actions.rename")} - - ) : null} - {onMarkAsRead ? ( - - Mark as read - - ) : null} - {onTogglePin ? ( - - {isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")} - - ) : null} - - {archiveLabel ?? t("sidebar.workspace.actions.archive")} - + + ); } +type ContextTriggerProps = Omit< + ComponentProps, + "children" | "enabledOnMobile" | "highlightStyle" +>; + +export function SidebarWorkspaceContextMenu({ + children, + contextMenuOpen, + onContextMenuOpenChange, + workspace, + leadingProjectName, + hostBadgeLabel, + serviceSummary, + workspaceKey, + onCopyPath, + onCopyBranchName, + onRename, + onMarkAsRead, + onArchive, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, + isPinned, + onTogglePin, + openInFileManagerPath, + accessibilityLabel, + highlightStyle, + ...triggerProps +}: PropsWithChildren< + SidebarWorkspaceMenuItemsProps & + ContextTriggerProps & { + contextMenuOpen: boolean; + onContextMenuOpenChange: (open: boolean) => void; + workspace: SidebarWorkspaceEntry; + leadingProjectName?: string | null; + hostBadgeLabel?: string | null; + serviceSummary?: WorkspaceServiceSummary | null; + highlightStyle: ComponentProps["highlightStyle"]; + } +>) { + const { + settings: { workspaceTitleSource }, + } = useAppSettings(); + const { t } = useTranslation(); + const pullRequestLabel = workspace.prHint + ? t("workspace.git.pr.accessibility.pullRequest", { + number: workspace.prHint.number, + context: getForgePresentation(normalizeForge(workspace.prHint.forge)).changeRequestContext, + }) + : null; + const rowAccessibilityLabel = resolveSidebarWorkspaceAccessibilityLabel({ + workspace, + workspaceTitleSource, + leadingProjectName, + hostBadgeLabel, + pullRequestLabel, + serviceLabel: serviceSummary + ? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name }) + : null, + }); + + return ( + + + {children} + + + + + + ); +} + function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.trigger, hovered && styles.triggerHovered]; } @@ -161,6 +358,9 @@ const styles = StyleSheet.create((theme) => ({ padding: 2, borderRadius: 4, marginLeft: 2, + // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but + // pull the painted dots through that unused view-box space onto the trailing-content rail. + marginRight: -7, }, triggerHovered: { backgroundColor: theme.colors.surface2, 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 c8629dec455..2e6b5ac5e54 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -1,73 +1,80 @@ -import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Pressable, - Text, - View, - type GestureResponderEvent, - type ViewStyle, -} from "react-native"; +import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react"; +import { Text, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; +import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native"; +import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual"; +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { - CircleAlert, - ExternalLink, - Folder, - FolderGit2, - GitPullRequest, - Globe, - Monitor, - SquareTerminal, -} from "lucide-react-native"; + WorkspaceMetaRow, + type WorkspaceServiceSummary, +} from "@/components/sidebar/workspace-meta-row"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; -import { SyncedLoader } from "@/components/synced-loader"; +import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { + hasSidebarWorkspaceTrailing, + type SidebarWorkspaceTrailing, +} from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; -import type { PrHint } from "@/git/use-pr-status-query"; -import { getForgePresentation, normalizeForge } from "@/git/forge"; -import { ForgeBrandIcon } from "@/git/forge-icon"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; +import { getStatusDotColor } from "@/utils/status-dot-color"; +import { + STATUS_INDICATOR_ALERT_SIZE, + STATUS_INDICATOR_DOT_SIZE, + STATUS_INDICATOR_FILLED_DOT_SIZE, +} from "@/utils/status-indicator-geometry"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; -import { openExternalUrl } from "@/utils/open-external-url"; +import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; -const DEFAULT_STATUS_DOT_SIZE = 7; -const EMPHASIZED_STATUS_DOT_SIZE = 9; -const DEFAULT_STATUS_DOT_OFFSET = 0; -const EMPHASIZED_STATUS_DOT_OFFSET = -1; +// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from +// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely. +const SCRIM_WIDTH = 48; +const SCRIM_SOLID_OFFSET = "55%"; -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); -const syncedLoaderColorMapping = (theme: Theme) => ({ - color: - theme.colorScheme === "light" - ? theme.colors.palette.amber[700] - : theme.colors.palette.amber[500], +const needsInputColorMapping = (theme: Theme) => ({ + color: theme.colors.surface0, + fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); -const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); -const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); -const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); -const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); -const ThemedExternalLink = withUnistyles(ExternalLink); -const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedMonitor = withUnistyles(Monitor); const ThemedFolder = withUnistyles(Folder); const ThemedFolderGit2 = withUnistyles(FolderGit2); -const ThemedGlobe = withUnistyles(Globe); -const ThemedSquareTerminal = withUnistyles(SquareTerminal); -function renderChecksBadgeForgeIcon(icon: string) { - return ; +/** + * react-native-svg's extractGradient reads stopColor off the child elements structurally, + * without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and + * the native gradient silently falls back to black. Theme the whole SVG instead and keep real + * Stop elements as direct children of the gradient. + */ +function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) { + return ( + + + + {/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward + `transparent` goes through black in some engines and leaves a grey fringe. */} + + + + + + + + ); } -type SidebarWorkspaceScriptIconKind = "service" | "command"; +const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg); + +const scrimColorMappings: Record { color: string }> = { + surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }), + surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }), + surface2: (theme) => ({ color: theme.colors.surface2 }), +}; export function SidebarWorkspaceRowFrame({ workspace, @@ -78,28 +85,50 @@ export function SidebarWorkspaceRowFrame({ isDragging?: boolean; children: (input: { isHovered: boolean; + contextMenuOpen: boolean; + onContextMenuOpenChange: (open: boolean) => void; hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void }; }) => ReactNode; }) { const [isHovered, setIsHovered] = useState(false); - const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + const handlePointerEnter = useCallback(() => { + if (!contextMenuOpen) setIsHovered(true); + }, [contextMenuOpen]); const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const handleContextMenuOpenChange = useCallback((open: boolean) => { + setContextMenuOpen(open); + if (open) setIsHovered(false); + }, []); const hoverHandlers = useMemo( () => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }), [handlePointerEnter, handlePointerLeave], ); return ( - - {children({ isHovered, hoverHandlers })} + + {children({ + isHovered: isHovered && !contextMenuOpen && !isDragging, + contextMenuOpen, + onContextMenuOpenChange: handleContextMenuOpenChange, + hoverHandlers, + })} ); } export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({ workspace, - subtitle, - scriptIconKind = null, + hostBadge, + leadingProjectName = null, + leadingProjectIconDataUri = null, + serviceSummary = null, + backdrop, isHovered, isLoading, isCreating = false, @@ -109,8 +138,13 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte children, }: { workspace: SidebarWorkspaceEntry; - subtitle?: string | null; - scriptIconKind?: SidebarWorkspaceScriptIconKind | null; + hostBadge?: HostBadgeModel | null; + /** Hoisted rows use their project icon as the leading visual because no project row contains them. */ + leadingProjectName?: string | null; + leadingProjectIconDataUri?: string | null; + serviceSummary?: WorkspaceServiceSummary | null; + /** The row's current background, so the project status badge can knock out of it. */ + backdrop: SidebarSurfaceBackdrop; isHovered: boolean; isLoading: boolean; isCreating?: boolean; @@ -127,43 +161,45 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte const workspaceBranchTextStyle = useMemo( () => [ styles.workspaceBranchText, - scriptIconKind ? styles.workspaceBranchTextWithAccessory : styles.workspaceBranchTextFlexible, isHovered && styles.workspaceBranchTextHovered, isCreating && styles.workspaceBranchTextCreating, ], - [scriptIconKind, isHovered, isCreating], + [isHovered, isCreating], ); return ( - + {leadingProjectName ? ( + + ) : ( + + )} - - - {workspaceLabel} - - {scriptIconKind ? : null} - + + {workspaceLabel} + {children} - {subtitle ? ( - - {subtitle} - - ) : null} - {workspace.prHint ? ( - - - - - ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( @@ -175,22 +211,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte ); }); -function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) { - return ( - - {kind === "service" ? ( - - ) : ( - - )} - - ); -} - function WorkspaceStatusIndicator({ bucket, workspaceKind, @@ -202,20 +222,22 @@ function WorkspaceStatusIndicator({ loading?: boolean; reserveIdleSpace?: boolean; }) { - const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); - + // Busy is the only status that moves, and it is the ring rather than a dot for the same + // reason it is a dot elsewhere: every status in the sidebar sits in this one slot, so busy + // has to fill it without displacing anything. A row starting up and a row working are both + // busy, so they share the ring and differ only in testID. if (loading) { return ( - + ); } - if (shouldShowSyncedLoader) { + if (shouldRenderSyncedStatusLoader({ bucket })) { return ( - + ); } @@ -223,7 +245,7 @@ function WorkspaceStatusIndicator({ if (bucket === "needs_input") { return ( - + ); } @@ -237,8 +259,14 @@ function WorkspaceStatusIndicator({ } if (bucket === "done") { + // An idle row still gets a dot rather than an empty slot. Nested rows are marked as + // workspaces by indentation alone, and with nothing in the leading slot the rail has no + // edge to read against — a workspace carrying its own glyph starts looking like a project + // header. The dot is muted to half opacity so it holds the rail without reporting status. return reserveIdleSpace ? ( - + + + ) : null; } @@ -248,127 +276,16 @@ function WorkspaceStatusIndicator({ else KindIcon = ThemedFolder; const dotColorStyle = getStatusDotColorStyle(bucket); - const statusDotSize = isEmphasizedStatusDotBucket(bucket) - ? EMPHASIZED_STATUS_DOT_SIZE - : DEFAULT_STATUS_DOT_SIZE; - const statusDotOffset = - statusDotSize === EMPHASIZED_STATUS_DOT_SIZE - ? EMPHASIZED_STATUS_DOT_OFFSET - : DEFAULT_STATUS_DOT_OFFSET; return ( - {dotColorStyle ? ( - - ) : null} - - ); -} - -function StatusDotOverlay({ - dotColorStyle, - size, - offset, -}: { - dotColorStyle: ViewStyle; - size: number; - offset: number; -}) { - const overlayStyle = useMemo( - () => [ - styles.statusDotOverlay, - dotColorStyle, - { - width: size, - height: size, - right: offset, - bottom: offset, - }, - ], - [dotColorStyle, offset, size], - ); - return ; -} - -function PrBadge({ hint }: { hint: PrHint }) { - const { t } = useTranslation(); - const [isHovered, setIsHovered] = useState(false); - const handlePress = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - void openExternalUrl(hint.url); - }, - [hint.url], - ); - const textStyle = useMemo( - () => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text), - [isHovered], - ); - const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); - const presentation = getForgePresentation(normalizeForge(hint.forge)); - - const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []); - const handleHoverIn = useCallback(() => setIsHovered(true), []); - const handleHoverOut = useCallback(() => setIsHovered(false), []); - - const pressableStyle = useMemo( - () => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed], - [isHovered], - ); - - return ( - - {isHovered ? ( - - ) : ( - - )} - - {presentation.numberPrefix} - {hint.number} - - - ); -} - -function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) { - if (!checks || checks.length === 0) return null; - const failed = checks.filter((check) => check.status === "failure").length; - if (failed === 0) return null; - const icon = getForgePresentation(normalizeForge(forge)).icon; - return ( - - {renderChecksBadgeForgeIcon(icon)} - {failed} failed + {dotColorStyle ? : null} ); } -function getPrIconUniMapping(state: PrHint["state"]) { - switch (state) { - case "merged": - return purpleColorMapping; - case "open": - return greenColorMapping; - case "closed": - return redColorMapping; - } +function StatusDotOverlay({ dotColorStyle }: { dotColorStyle: ViewStyle }) { + return ; } function getStatusDotColorStyle(bucket: SidebarStateBucket) { @@ -386,41 +303,18 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) { } } -const prBadgeStyles = StyleSheet.create((theme) => ({ - badge: { - flexDirection: "row", - alignItems: "center", - gap: 2, - }, - badgePressed: { - opacity: 0.82, - }, - text: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - lineHeight: 14, - color: theme.colors.foregroundMuted, - }, - textHovered: { - color: theme.colors.foreground, - }, -})); - -const checksBadgeStyles = StyleSheet.create((theme) => ({ - badge: { - flexDirection: "row", - alignItems: "center", - gap: 2, - }, - text: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - lineHeight: 14, - color: theme.colors.palette.red[500], - }, -})); - export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ + // How far a workspace row sits inside the group header above it — a project row or a + // status group header. Both groupings share this one indent, so every grouped workspace row + // in the sidebar sits on the same rail regardless of how the list is grouped. Pinned rows + // are not grouped and stay flush. + // + // It is row padding rather than a margin on the list, because the row's hover and selected + // backgrounds have to keep spanning the group's full width. Indenting the container instead + // pulls the highlight in with the content and the row stops lining up with its header. + rowIndented: { + paddingLeft: theme.spacing[2] + theme.spacing[2], + }, rowRight: { flexDirection: "row", alignItems: "flex-start", @@ -446,7 +340,16 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ lineHeight: 14, }, hidden: { opacity: 0 }, + // Stays position:relative at zero width so the absolutely-positioned kebab keeps + // anchoring to the same right edge whether or not the slot holds anything. trailingActionSlot: { + position: "relative", + minHeight: 20, + flexShrink: 0, + alignItems: "flex-end", + justifyContent: "flex-start", + }, + trailingActionSlotReserved: { position: "relative", minWidth: 18, minHeight: 20, @@ -459,6 +362,13 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ top: 0, right: 0, }, + trailingActionScrim: { + position: "absolute", + top: 0, + bottom: 0, + right: 0, + width: SCRIM_WIDTH, + }, })); export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { @@ -469,8 +379,74 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { ); } -export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) { - return {children}; +/** + * 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 + * into each of them and immediately drifted — one call site kept hiding the diff after the + * others stopped. + * + * The trailing content survives the kebab on hover and fades under the scrim instead of + * blinking out. Touch has no hover, so its permanent kebab still hides the content outright + * rather than scrimming an unhovered row whose background doesn't match the gradient. + */ +export function resolveTrailingActionVisibility({ + workspace, + trailing, + hasArchiveAction, + isHovered, + isTouchPlatform, + showShortcut, +}: { + workspace: SidebarWorkspaceEntry; + trailing: SidebarWorkspaceTrailing; + hasArchiveAction: boolean; + isHovered: boolean; + isTouchPlatform: boolean; + showShortcut: boolean; +}): { + showTrailing: boolean; + showKebab: boolean; + showScrim: boolean; + renderSlot: boolean; + reserveSlotWidth: boolean; +} { + const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing }); + const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut; + const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab); + return { + showTrailing, + 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 + // touch, which shows the kebab without ever hovering, never gets one. + showScrim: showKebab && isHovered, + renderSlot: hasArchiveAction || hasTrailing, + // The slot only holds width for something that permanently sits in it. Trailing content + // 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), + }; +} + +export function SidebarWorkspaceTrailingActionSlot({ + reserveWidth, + children, +}: { + reserveWidth: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); } export function SidebarWorkspaceTrailingActionBase({ @@ -486,13 +462,49 @@ export function SidebarWorkspaceTrailingActionBase({ export function SidebarWorkspaceTrailingActionOverlay({ visible, + scrimBackdrop, children, }: { visible: boolean; + /** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */ + scrimBackdrop?: SidebarSurfaceBackdrop; children: ReactNode; }) { if (!visible || !children) return null; - return {children}; + return ( + <> + {scrimBackdrop ? : null} + {children} + + ); +} + +/** + * The row's own background, faded in from the right, sitting between the diff stat and the + * kebab. The kebab lands on fully opaque background while the diff dissolves underneath it + * rather than blinking out — hiding the diff outright was the old behavior and it cost a + * visible flicker on every hover. + * + * Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose: + * the fade has to start before the diff stat does or the diff's left edge cuts off hard. + */ +function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) { + // useId's output contains characters that are not legal inside url(#...) — React 19 wraps + // ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all. + // Keep the per-instance uniqueness, drop everything a fragment reference can't carry. + const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + return ( + + + + ); } const styles = StyleSheet.create((theme) => ({ @@ -515,13 +527,6 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "space-between", gap: theme.spacing[2], }, - workspaceTitleLeft: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flex: 1, - minWidth: 0, - }, shortcutBadgeOverlay: { position: "absolute", top: 1, @@ -538,34 +543,36 @@ const styles = StyleSheet.create((theme) => ({ }, statusDotOverlay: { position: "absolute", + right: 0, + bottom: 0, + width: STATUS_INDICATOR_DOT_SIZE, + height: STATUS_INDICATOR_DOT_SIZE, borderRadius: theme.borderRadius.full, borderWidth: 1, }, standaloneStatusDot: { - width: 8, - height: 8, + width: STATUS_INDICATOR_FILLED_DOT_SIZE, + height: STATUS_INDICATOR_FILLED_DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, + }, + idleStatusDot: { + width: STATUS_INDICATOR_FILLED_DOT_SIZE, + height: STATUS_INDICATOR_FILLED_DOT_SIZE, borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.palette.green[500], + backgroundColor: theme.colors.foregroundExtraMuted, + opacity: 0.3, }, + // The title owns the first line outright now that the host, change request and CI moved + // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", lineHeight: 20, opacity: 0.76, - minWidth: 0, - }, - workspaceBranchTextFlexible: { flex: 1, - }, - workspaceBranchTextWithAccessory: { - flexShrink: 1, - }, - workspaceTitleAccessory: { - height: 20, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", + minWidth: 0, }, workspaceBranchTextCreating: { opacity: 0.92, @@ -573,31 +580,20 @@ const styles = StyleSheet.create((theme) => ({ workspaceBranchTextHovered: { opacity: 1, }, - workspaceSubtitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - lineHeight: 14, - }, - workspacePrBadgeRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - marginTop: theme.spacing[1], - }, statusDotNeedsInput: { - backgroundColor: theme.colors.palette.amber[500], + backgroundColor: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, borderColor: theme.colors.surface0, }, statusDotFailed: { - backgroundColor: theme.colors.palette.red[500], + backgroundColor: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, borderColor: theme.colors.surface0, }, statusDotRunning: { - backgroundColor: theme.colors.palette.blue[500], + backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, borderColor: theme.colors.surface0, }, statusDotAttention: { - backgroundColor: theme.colors.palette.green[500], + backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, borderColor: theme.colors.surface0, }, })); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 5f7075bf308..87c28cf64f1 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -1,13 +1,14 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; -import { View, Text, Pressable } from "react-native"; +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 type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { DiffStat } from "@/components/diff-stat"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { useToast } from "@/contexts/toast-context"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; @@ -20,14 +21,26 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { isNative as platformIsNative } from "@/constants/platform"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; -import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu"; +import { + SidebarWorkspaceContextMenu, + SidebarWorkspaceMenu, +} from "@/components/sidebar/sidebar-workspace-menu"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, + resolveTrailingActionVisibility, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; +import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility"; +import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; +import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row"; +import { + SidebarWorkspaceTrailingContent, + useSidebarWorkspaceTrailing, + type SidebarWorkspaceTrailing, +} from "@/components/sidebar/workspace-trailing"; function noop() {} @@ -38,8 +51,8 @@ interface SidebarWorkspaceRowProps { showShortcutBadge: boolean; canCopyBranchName: boolean; onPress: () => void; - /** Secondary line under the name (status grouping shows the project name). */ - subtitle?: string | null; + /** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */ + hostBadge?: HostBadgeModel | null; /** Project grouping only: shows a transient "creating" affordance. */ isCreating?: boolean; /** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */ @@ -55,7 +68,7 @@ export function SidebarWorkspaceRow({ showShortcutBadge, canCopyBranchName, onPress, - subtitle, + hostBadge, isCreating = false, drag, isDragging = false, @@ -175,7 +188,7 @@ export function SidebarWorkspaceRow({ selected={selected} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} - subtitle={subtitle} + hostBadge={hostBadge} isCreating={isCreating} isArchiving={isArchiving} onPress={onPress} @@ -211,7 +224,7 @@ interface WorkspaceRowBodyProps { selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; - subtitle?: string | null; + hostBadge?: HostBadgeModel | null; isCreating: boolean; isArchiving: boolean; onPress: () => void; @@ -234,7 +247,7 @@ function WorkspaceRowBody({ selected, shortcutNumber, showShortcutBadge, - subtitle, + hostBadge, isCreating, isArchiving, onPress, @@ -252,6 +265,8 @@ function WorkspaceRowBody({ archiveShortcutKeys, }: WorkspaceRowBodyProps) { const isTouchPlatform = platformIsNative; + const [isPressed, setIsPressed] = useState(false); + const trailing = useSidebarWorkspaceTrailing(); const draggable = Boolean(drag); const interaction = useLongPressDragInteraction({ drag: drag ?? noop, @@ -271,22 +286,32 @@ function WorkspaceRowBody({ } onPress(); }, [interaction.didLongPressRef, onPress]); + const handleWorkspacePressIn = useCallback( + (event: GestureResponderEvent) => { + setIsPressed(true); + if (draggable) interaction.handlePressIn(event); + }, + [draggable, interaction], + ); + const handleWorkspacePressOut = useCallback(() => { + setIsPressed(false); + if (draggable) interaction.handlePressOut(); + }, [draggable, interaction]); const accessibilityState = useMemo(() => ({ selected }), [selected]); return ( - {({ isHovered, hoverHandlers }) => { + {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const isDesktop = !isTouchPlatform; - const showScriptsIcon = isDesktop && workspace.hasRunningScripts; - const hasRunningService = workspace.scripts.some( - (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", - ); - let scriptIconKind: "service" | "command" | null = null; - if (showScriptsIcon) { - scriptIconKind = hasRunningService ? "service" : "command"; - } - const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered }); + const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; + const workspaceRowStyle = getWorkspaceRowStyle({ + isDragging, + isPressed, + selected, + isHovered, + }); + const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered }); return ( - - + ); }} @@ -347,6 +392,8 @@ function WorkspaceRowBody({ function WorkspaceRowTrailingActions({ workspace, + backdrop, + trailing, isHovered, isTouchPlatform, isCreating, @@ -363,6 +410,8 @@ function WorkspaceRowTrailingActions({ onRename, }: { workspace: SidebarWorkspaceEntry; + backdrop: SidebarSurfaceBackdrop; + trailing: SidebarWorkspaceTrailing; isHovered: boolean; isTouchPlatform: boolean; isCreating: boolean; @@ -380,30 +429,39 @@ function WorkspaceRowTrailingActions({ }) { const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; - const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); - const showKebabInSlot = showKebab && !showShortcut; - const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); + const { + showTrailing, + showKebab: showKebabInSlot, + showScrim, + renderSlot, + reserveSlotWidth, + } = resolveTrailingActionVisibility({ + workspace, + trailing, + hasArchiveAction: Boolean(onArchive), + isHovered, + isTouchPlatform, + showShortcut, + }); + const kebab = useOpenKebabMenuVisibility(showKebabInSlot); return ( <> {isCreating ? ( {t("sidebar.workspace.status.creating")} ) : null} - {shouldRenderActionSlot ? ( - - - {workspace.diffStat ? ( - - ) : null} + {renderSlot ? ( + + + - + {onArchive ? ( ({ workspaceRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, + workspaceRowPressed: { + backgroundColor: theme.colors.surface2, + }, workspaceRowDragging: { backgroundColor: theme.colors.surface2, borderWidth: 1, 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 9b91dba9d2c..2068dbe271d 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; +import { + resolveSidebarWorkspaceAccessibilityLabel, + resolveSidebarWorkspacePrimaryLabel, +} from "@/components/sidebar/sidebar-workspace-title"; describe("resolveSidebarWorkspacePrimaryLabel", () => { it("uses the workspace name in title mode", () => { @@ -29,3 +32,45 @@ describe("resolveSidebarWorkspacePrimaryLabel", () => { expect(label).toBe("Local folder"); }); }); + +describe("resolveSidebarWorkspaceAccessibilityLabel", () => { + it("includes the visible host badge with the workspace title", () => { + const label = resolveSidebarWorkspaceAccessibilityLabel({ + workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" }, + workspaceTitleSource: "title", + hostBadgeLabel: "Build host", + }); + + expect(label).toBe("Investigate search, Build host"); + }); + + it("owns every visual row contributor in one accessible label", () => { + const label = resolveSidebarWorkspaceAccessibilityLabel({ + workspace: { + name: "Investigate search", + currentBranch: "fix/search", + statusBucket: "running", + }, + workspaceTitleSource: "branch", + leadingProjectName: "Search project", + hostBadgeLabel: "Build host", + pullRequestLabel: "Pull request 42", + serviceLabel: "Service web running", + }); + + expect(label).toBe( + "Search project, fix/search, Build host, Pull request 42, Service web running, Working", + ); + }); + + it("omits the idle status from the workspace label", () => { + const label = resolveSidebarWorkspaceAccessibilityLabel({ + workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" }, + workspaceTitleSource: "title", + leadingProjectName: "Search project", + hostBadgeLabel: "Build host", + }); + + 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 1ad4e6616c2..955c9d26361 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts @@ -1,5 +1,6 @@ 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"; export function resolveSidebarWorkspacePrimaryLabel(input: { workspace: Pick; @@ -10,3 +11,25 @@ export function resolveSidebarWorkspacePrimaryLabel(input: { } return input.workspace.name; } + +export function resolveSidebarWorkspaceAccessibilityLabel(input: { + workspace: Pick; + workspaceTitleSource: WorkspaceTitleSource; + leadingProjectName?: string | null; + hostBadgeLabel?: string | null; + pullRequestLabel?: string | null; + serviceLabel?: string | null; +}): string { + return [ + input.leadingProjectName, + resolveSidebarWorkspacePrimaryLabel(input), + input.hostBadgeLabel, + input.pullRequestLabel, + input.serviceLabel, + input.workspace.statusBucket === "done" + ? null + : STATUS_BUCKET_LABELS[input.workspace.statusBucket], + ] + .filter((label): label is string => Boolean(label)) + .join(", "); +} diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index 747f2b6bcdd..8e3a508b3b0 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -9,6 +9,7 @@ export function useLongPressDragInteraction(input: { drag: () => void; menuController: ReturnType | null; }) { + const { drag, menuController } = input; const didLongPressRef = useRef(false); const dragArmedRef = useRef(false); const dragActivatedRef = useRef(false); @@ -32,20 +33,20 @@ export function useLongPressDragInteraction(input: { }, []); const openContextMenuAtStartPoint = useCallback(() => { - if (!input.menuController || !touchStartRef.current) { + if (!menuController || !touchStartRef.current) { return; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; - input.menuController.setAnchorRect({ + menuController.setAnchorRect({ x: touchStartRef.current.x, y: touchStartRef.current.y + statusBarHeight, width: 0, height: 0, }); - input.menuController.setOpen(true); + menuController.setOpen(true); menuOpenedRef.current = true; didLongPressRef.current = true; - }, [input.menuController]); + }, [menuController]); const handleLongPress = useCallback(() => { // Manual timers own long-press behavior on mobile. @@ -87,10 +88,10 @@ export function useLongPressDragInteraction(input: { dragActivatedRef.current = true; didLongPressRef.current = true; void Haptics.selectionAsync().catch(() => {}); - input.drag(); + drag(); }, DRAG_ARM_DELAY_MS); - if (!input.menuController) { + if (!menuController) { return; } @@ -112,7 +113,7 @@ export function useLongPressDragInteraction(input: { void Haptics.selectionAsync().catch(() => {}); openContextMenuAtStartPoint(); }, CONTEXT_MENU_DELAY_MS); - }, [clearTimers, input, openContextMenuAtStartPoint]); + }, [clearTimers, drag, menuController, openContextMenuAtStartPoint]); const handleDragIntent = useCallback( (_details: { dx: number; dy: number; distance: number }) => { diff --git a/packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts b/packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts new file mode 100644 index 00000000000..a0049707c4c --- /dev/null +++ b/packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts @@ -0,0 +1,20 @@ +import { useMemo, useState } from "react"; + +/** + * Keeps a row's kebab in its slot for as long as the menu it opened is up. + * + * The kebab is revealed by hover, and the surface it opens can take the pointer off the row — + * on a compact layout the sheet slides up over it. The row then reads as un-hovered, the + * trailing overlay unmounts its own trigger mid-gesture, and the open menu is left with no + * state that can close it: on a sheet that strands a full-screen backdrop over the app until + * reload. Lifting the menu's open state to the component that decides whether to render it is + * what stops the row from pulling the ground out from under it. + */ +export function useOpenKebabMenuVisibility(showKebab: boolean): { + showKebab: boolean; + menuProps: { open: boolean; onOpenChange: (open: boolean) => void }; +} { + const [open, setOpen] = useState(false); + const menuProps = useMemo(() => ({ open, onOpenChange: setOpen }), [open]); + return { showKebab: showKebab || open, menuProps }; +} diff --git a/packages/app/src/components/sidebar/workspace-meta-row/check-indicator.tsx b/packages/app/src/components/sidebar/workspace-meta-row/check-indicator.tsx new file mode 100644 index 00000000000..36d53e7a3b1 --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/check-indicator.tsx @@ -0,0 +1,102 @@ +import Svg, { Circle, Path } from "react-native-svg"; +import { withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; +import { toFilledQuarters, type CheckSummary } from "./check-summary"; + +/** + * Geometry in a 24-unit box scaled to the icon size, so the stroke weight matches the + * lucide icons sitting next to it in the row. The outline radius leaves half the stroke + * inside the box on each side; the pie is inset far enough to read as a separate shape + * rather than a thick ring. + */ +const VIEW_BOX = 24; +const STROKE_WIDTH = 2; +const OUTLINE_RADIUS = (VIEW_BOX - STROKE_WIDTH) / 2; +const CENTER = VIEW_BOX / 2; +const PIE_RADIUS = 7; + +const CHECK_PATH = "M8 12.5 L11 15.5 L16.5 9"; +const CROSS_PATH = "M8.5 8.5 L15.5 15.5 M15.5 8.5 L8.5 15.5"; + +/** + * A change request's CI as one glyph: a check inside a circle when everything passed, a + * cross when anything failed, and a pie filling clockwise from twelve while the run is + * still going. The three share the circle so a row's CI always occupies the same shape and + * only its interior changes. + */ +function CheckIndicatorSvg({ + summary, + size, + color, +}: { + summary: CheckSummary; + size: number; + color: string; +}) { + return ( + + + {summary.state === "running" ? ( + + ) : ( + + )} + + ); +} + +/** + * A filled wedge from the centre, starting at twelve o'clock and sweeping clockwise. A full + * circle is drawn as a circle rather than an arc: an arc whose start and end points + * coincide has nothing to sweep between and paints nothing at all. + */ +function Pie({ fraction, color }: { fraction: number; color: string }) { + if (fraction <= 0) return null; + if (fraction >= 1) { + return ; + } + + const angle = fraction * 2 * Math.PI; + const endX = CENTER + PIE_RADIUS * Math.sin(angle); + const endY = CENTER - PIE_RADIUS * Math.cos(angle); + const largeArc = fraction > 0.5 ? 1 : 0; + const d = [ + `M ${CENTER} ${CENTER}`, + `L ${CENTER} ${CENTER - PIE_RADIUS}`, + `A ${PIE_RADIUS} ${PIE_RADIUS} 0 ${largeArc} 1 ${endX} ${endY}`, + "Z", + ].join(" "); + return ; +} + +const ThemedCheckIndicatorSvg = withUnistyles(CheckIndicatorSvg); + +const COLOR_MAPPINGS: Record { color: string }> = { + passed: (theme) => ({ color: theme.colors.statusSuccess }), + failed: (theme) => ({ color: theme.colors.statusDanger }), + running: (theme) => ({ color: theme.colors.statusWarning }), +}; + +export function CheckIndicator({ summary, size }: { summary: CheckSummary; size: number }) { + return ( + + ); +} diff --git a/packages/app/src/components/sidebar/workspace-meta-row/check-summary.test.ts b/packages/app/src/components/sidebar/workspace-meta-row/check-summary.test.ts new file mode 100644 index 00000000000..6dcaaf5770e --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/check-summary.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { PrHint } from "@/git/pr-hint"; +import { selectCheckSummary, toFilledQuarters } from "./check-summary"; + +function hint(overrides: Partial): PrHint { + return { + url: "https://github.com/o/r/pull/1", + number: 1, + state: "open", + forge: "github", + ...overrides, + }; +} + +function checks(...statuses: string[]): PrHint["checks"] { + return statuses.map((status, index) => ({ name: `check-${index}`, status, url: null })); +} + +describe("selectCheckSummary", () => { + it("returns nothing without a pull request", () => { + expect(selectCheckSummary(null)).toBeNull(); + }); + + it("returns nothing when the pull request reports no checks", () => { + expect(selectCheckSummary(hint({ checksStatus: "none" }))).toBeNull(); + expect(selectCheckSummary(hint({ checks: [] }))).toBeNull(); + }); + + it("passes once every check is terminal and none failed", () => { + expect(selectCheckSummary(hint({ checks: checks("success", "success", "skipped") }))).toEqual({ + state: "passed", + completed: 3, + total: 3, + }); + }); + + it("counts terminal checks while others are still pending", () => { + expect( + selectCheckSummary(hint({ checks: checks("success", "skipped", "pending", "pending") })), + ).toEqual({ state: "running", completed: 2, total: 4 }); + }); + + it("fails as soon as one check fails, even mid-run", () => { + expect( + selectCheckSummary(hint({ checks: checks("success", "failure", "pending", "pending") })), + ).toEqual({ state: "failed", completed: 4, total: 4 }); + }); + + it("treats cancelled checks as finished rather than failed", () => { + expect(selectCheckSummary(hint({ checks: checks("success", "cancelled") }))).toEqual({ + state: "passed", + completed: 2, + total: 2, + }); + }); + + it("treats an unrecognized status as still running", () => { + expect(selectCheckSummary(hint({ checks: checks("success", "queued") }))).toEqual({ + state: "running", + completed: 1, + total: 2, + }); + }); + + it("falls back to the summary status when individual checks are absent", () => { + expect(selectCheckSummary(hint({ checksStatus: "pending" }))).toEqual({ + state: "running", + completed: 0, + total: 1, + }); + expect(selectCheckSummary(hint({ checksStatus: "failure" }))).toEqual({ + state: "failed", + completed: 1, + total: 1, + }); + }); + + it("prefers individual checks over the summary status", () => { + expect( + selectCheckSummary(hint({ checks: checks("failure"), checksStatus: "success" })), + ).toEqual({ state: "failed", completed: 1, total: 1 }); + }); +}); + +describe("toFilledQuarters", () => { + it("fills nothing before the first quarter completes", () => { + expect(toFilledQuarters({ state: "running", completed: 0, total: 8 })).toBe(0); + expect(toFilledQuarters({ state: "running", completed: 1, total: 8 })).toBe(0); + }); + + it("fills in quarter steps", () => { + expect(toFilledQuarters({ state: "running", completed: 2, total: 8 })).toBe(0.25); + expect(toFilledQuarters({ state: "running", completed: 5, total: 10 })).toBe(0.5); + expect(toFilledQuarters({ state: "running", completed: 6, total: 8 })).toBe(0.75); + }); + + it("rounds down so an unfinished run never reads as complete", () => { + expect(toFilledQuarters({ state: "running", completed: 9, total: 10 })).toBe(0.75); + }); + + it("closes the pie only when every check is in", () => { + expect(toFilledQuarters({ state: "passed", completed: 10, total: 10 })).toBe(1); + }); + + it("treats an empty run as empty rather than dividing by zero", () => { + expect(toFilledQuarters({ state: "running", completed: 0, total: 0 })).toBe(0); + }); +}); diff --git a/packages/app/src/components/sidebar/workspace-meta-row/check-summary.ts b/packages/app/src/components/sidebar/workspace-meta-row/check-summary.ts new file mode 100644 index 00000000000..35828b641be --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/check-summary.ts @@ -0,0 +1,72 @@ +import { mapCheckStatus } from "@/git/pull-request-panel/check-status"; +import type { PrHint } from "@/git/pr-hint"; + +/** + * What the sidebar reports about a change request's CI, reduced to the three things a row + * can show. "running" carries a fraction so the indicator can fill as checks land. + */ +export type CheckSummaryState = "passed" | "failed" | "running"; + +export interface CheckSummary { + state: CheckSummaryState; + /** Checks that have reached a terminal status. Equals `total` when state is not "running". */ + completed: number; + total: number; +} + +/** + * Failure wins over progress. A run that is half done with one failure already needs the + * user to open the hover card, and a ring still filling says "wait" — so the moment any + * check fails the summary is "failed" and the fraction stops mattering. + * + * `checks` is the richer source and the only one that can produce a fraction. Forges that + * report a summary without the individual checks fall back to `checksStatus`, which + * degrades the ring to empty-or-full rather than dropping the indicator. + */ +export function selectCheckSummary(hint: PrHint | null): CheckSummary | null { + if (!hint) return null; + return summarizeChecks(hint.checks) ?? summarizeChecksStatus(hint.checksStatus); +} + +/** The pie has four pieces, so a run reports progress in quarters. */ +const PIE_PIECES = 4; + +/** + * How much of the pie is filled, as a fraction, snapped down to a quarter. Rounding down + * rather than to nearest means the indicator never claims a check finished that hasn't — + * nine of ten reads as three quarters, and only the tenth closes the circle. + */ +export function toFilledQuarters(summary: CheckSummary): number { + if (summary.total === 0) return 0; + return Math.floor((summary.completed / summary.total) * PIE_PIECES) / PIE_PIECES; +} + +function summarizeChecks(checks: PrHint["checks"]): CheckSummary | null { + if (!checks || checks.length === 0) return null; + + let completed = 0; + let failed = false; + for (const check of checks) { + const status = mapCheckStatus(check.status); + if (status === "failure") failed = true; + if (status !== "pending") completed += 1; + } + + const total = checks.length; + if (failed) return { state: "failed", completed: total, total }; + if (completed === total) return { state: "passed", completed: total, total }; + return { state: "running", completed, total }; +} + +function summarizeChecksStatus(checksStatus: PrHint["checksStatus"]): CheckSummary | null { + switch (checksStatus) { + case "success": + return { state: "passed", completed: 1, total: 1 }; + case "failure": + return { state: "failed", completed: 1, total: 1 }; + case "pending": + return { state: "running", completed: 0, total: 1 }; + default: + return null; + } +} diff --git a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx new file mode 100644 index 00000000000..e7d56ff0254 --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx @@ -0,0 +1,339 @@ +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, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + Globe, +} from "lucide-react-native"; +import type { HostBadgeModel } from "@/hosts/appearance"; +import { HostBadge, HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge"; +import type { PrHint } from "@/git/pr-hint"; +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 { CheckIndicator } from "./check-indicator"; +import type { CheckSummary, CheckSummaryState } from "./check-summary"; +import { selectMetaRowItems, type MetaRowItem } from "./meta-items"; +import { workspaceServiceLabelKey, type WorkspaceServiceSummary } from "./service-summary"; + +export { + selectWorkspaceServiceSummary, + workspaceServiceLabelKey, + type WorkspaceServiceSummary, +} from "./service-summary"; + +/** + * One size for every glyph on the line. The items are peers — host, change request, CI, + * running service — so a glyph that differs in size reads as a different rank. The host badge + * owns the size because it is the one item that also appears off this line. + */ +const META_ICON_SIZE = HOST_BADGE_ICON_SIZE; + +const ThemedExternalLink = withUnistyles(ExternalLink); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const ThemedGitMerge = withUnistyles(GitMerge); +const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed); +const ThemedGlobe = withUnistyles(Globe); + +const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMerged }); +const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); + +/** + * The subtitle under a workspace title: which host it lives on, its change request, that + * change request's CI, and any running service. Everything the row knows about a workspace + * that isn't its name. + * + * Items are peers separated by a dot rather than ranked by chrome. The host used to be a + * tinted pill on the title line, which made it the loudest thing in a row whose subject is + * the title; flattening it lets the whole line read as one piece of secondary text and + * leaves color to mean status. + */ +export function WorkspaceMetaRow({ + hostBadge, + prHint, + serviceSummary, +}: { + hostBadge: HostBadgeModel | null; + prHint: PrHint | null; + serviceSummary: WorkspaceServiceSummary | null; +}) { + const { rowItems, checksDisplay } = useSidebarMetaPreferences(); + const items = selectMetaRowItems({ + hasHostBadge: hostBadge !== null, + prHint, + serviceSummary, + visible: rowItems, + checksDisplay, + }); + + if (items.length === 0) return null; + + return ( + + {items.map((item, index) => ( + + {index > 0 ? · : null} + + + ))} + + ); +} + +function MetaItemNode({ + item, + hostBadge, +}: { + item: MetaRowItem; + hostBadge: HostBadgeModel | null; +}): ReactNode { + if (item.kind === "host") { + return hostBadge ? : null; + } + if (item.kind === "changeRequest") { + return ; + } + if (item.kind === "checks") { + return ; + } + return ; +} + +/** + * The only thing on the line that navigates, so it is the only thing that answers the + * pointer: it brightens to full foreground and its icon becomes an external-link arrow, + * saying where the click goes before you spend it. + * + * `onHoverIn`/`onHoverOut` with local state is safe here despite the usual rule in + * docs/hover.md — the state never leaves this Pressable, and nothing pressable is nested + * inside it, so there is no second hover state machine to fight. Both icons are the same + * size, so the swap can't move the target out from under the cursor. + */ +function PullRequestItem({ hint }: { hint: PrHint }) { + const { t } = useTranslation(); + const [isHovered, setIsHovered] = useState(false); + const presentation = getForgePresentation(normalizeForge(hint.forge)); + + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + void openExternalUrl(hint.url); + }, + [hint.url], + ); + const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + + const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state]; + return ( + + + + {hint.number} + {/* An open change request is the unremarkable case and says nothing extra; a merged + or closed one is why the row still looks like it has work in it. */} + {hint.state === "open" ? "" : ` ${t(PR_STATE_LABEL_KEYS[hint.state])}`} + + + ); +} + +/** + * By default every state says its own name. A tick on its own has no subject — the reader has to + * already know the glyph is about CI to read it at all, and sitting after a host and a change + * request it reads as something left over. One short word costs less than that ambiguity, which + * is why icon-and-text is the default and dropping the word is something you ask for. + * + * The words are the outcome, not the noun: "passed" against "failed", both naming how a finished + * run ended, "running" for one still deciding. Repeating the subject on every healthy row — + * "checks passed" — is the noise worth avoiding; naming the outcome is not. + * + * The accessible name is on the wrapper either way, so turning the word off shrinks the row + * without taking the state away from a screen reader. + */ +function ChecksItem({ summary, label }: { summary: CheckSummary; label: boolean }) { + const { t } = useTranslation(); + return ( + + + {label ? ( + + {t(CHECK_STATE_LABEL_KEYS[summary.state])} + + ) : null} + + ); +} + +const CHECK_STATE_LABEL_KEYS = { + passed: "workspace.git.pr.checksSummary.passedLabel", + failed: "workspace.git.pr.checksSummary.failedLabel", + running: "workspace.git.pr.checksSummary.runningLabel", +} as const; + +const CHECK_STATE_ACCESSIBLE_KEYS = { + passed: "workspace.git.pr.checksSummary.passedAccessible", + failed: "workspace.git.pr.checksSummary.failedAccessible", + running: "workspace.git.pr.checksSummary.runningAccessible", +} as const; + +/** + * A running service, named. It is the one item on the line whose text is arbitrary — everything + * else is a number, a short word, or a host label the user already picked — so it is also the + * only one allowed to shrink, and it truncates rather than pushing the line past the row. + * + * A failed health check turns the whole item danger, glyph and name together. Colouring only the + * glyph would leave the name reading as fine, and the name is the part you look at. + */ +function ServiceItem({ summary }: { summary: WorkspaceServiceSummary }) { + const { t } = useTranslation(); + const unhealthy = summary.health === "unhealthy"; + return ( + + + + {summary.name} + + + ); +} + +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", +} as const; + +function pressableItemStyle({ pressed }: { pressed: boolean }) { + return [styles.item, pressed && styles.itemPressed]; +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + minWidth: 0, + }, + item: { + flexDirection: "row", + alignItems: "center", + gap: 3, + minWidth: 0, + flexShrink: 0, + }, + itemPressed: { + opacity: 0.82, + }, + separator: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 0, + }, + // The one item that gives way when the line runs out of room — see `ServiceItem`. + serviceItem: { + flexDirection: "row", + alignItems: "center", + gap: 3, + minWidth: 0, + flexShrink: 1, + }, + serviceName: { + color: theme.colors.statusSuccess, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 1, + }, + serviceNameUnhealthy: { + color: theme.colors.statusDanger, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 1, + }, + prText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 0, + }, + prTextHovered: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 0, + }, + // Matches the indicator — see COLOR_MAPPINGS in check-indicator.tsx. + checksTextPassed: { + color: theme.colors.statusSuccess, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 0, + }, + checksTextFailed: { + color: theme.colors.statusDanger, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 0, + }, + checksTextRunning: { + color: theme.colors.statusWarning, + fontSize: theme.fontSize.xs, + lineHeight: 16, + flexShrink: 0, + }, +})); + +// Read inside render, never into a module-scope table: touching `styles.x` at module load +// materializes the Unistyles proxy before the persisted theme has resolved, and the style +// freezes on whatever theme happened to be active first. See docs/unistyles.md. +function checksTextStyle(state: CheckSummaryState) { + if (state === "failed") return styles.checksTextFailed; + if (state === "running") return styles.checksTextRunning; + return styles.checksTextPassed; +} diff --git a/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts new file mode 100644 index 00000000000..fc3c424fa65 --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { PrHint } from "@/git/pr-hint"; +import { DEFAULT_SIDEBAR_CHECKS_DISPLAY } from "@/components/sidebar/display-preferences/checks-display"; +import { DEFAULT_SIDEBAR_ROW_ITEMS } from "@/components/sidebar/display-preferences/row-items"; +import { selectMetaRowItems } from "./meta-items"; +import type { WorkspaceServiceSummary } from "./service-summary"; + +const PR_HINT: PrHint = { + url: "https://github.com/acme/app/pull/7", + number: 7, + state: "open", + forge: "github", + checksStatus: "success", +}; + +const SERVICE: WorkspaceServiceSummary = { name: "web", health: null }; + +function select(overrides: Partial[0]> = {}) { + return selectMetaRowItems({ + hasHostBadge: true, + prHint: PR_HINT, + serviceSummary: SERVICE, + visible: DEFAULT_SIDEBAR_ROW_ITEMS, + checksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY, + ...overrides, + }); +} + +const kinds = (items: ReturnType) => items.map((item) => item.kind); + +describe("selectMetaRowItems", () => { + it("reads identity, then the change, then its state, then what is running", () => { + expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "services"]); + }); + + it("omits what the workspace does not have", () => { + expect(kinds(select({ hasHostBadge: false, prHint: null, serviceSummary: null }))).toEqual([]); + }); + + it.each([ + ["changeRequest", ["host", "checks", "services"]], + ["services", ["host", "changeRequest", "checks"]], + ] as const)("drops %s and only %s when it is switched off", (item, expected) => { + expect(kinds(select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false } }))).toEqual( + expected, + ); + }); + + it("drops checks and only checks when they are hidden", () => { + expect(kinds(select({ checksDisplay: "none" }))).toEqual(["host", "changeRequest", "services"]); + }); + + it("keeps checks when the change request is hidden", () => { + // Each control answers for itself. A checks setting that drew nothing because a different + // switch was off would be lying about its own state. + const items = select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false } }); + expect(kinds(items)).toEqual(["host", "checks", "services"]); + }); + + it("draws nothing for checks when both are off", () => { + const items = select({ + visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false }, + checksDisplay: "none", + }); + expect(kinds(items)).toEqual(["host", "services"]); + }); + + it("carries the resolved check summary rather than the raw hint", () => { + const checks = select().find((item) => item.kind === "checks"); + expect(checks).toEqual({ + kind: "checks", + summary: { state: "passed", completed: 1, total: 1 }, + label: true, + }); + }); + + it("keeps the check summary but drops its word when only the icon is wanted", () => { + const checks = select({ checksDisplay: "icon" }).find((item) => item.kind === "checks"); + expect(checks).toEqual({ + kind: "checks", + summary: { state: "passed", completed: 1, total: 1 }, + label: false, + }); + }); + + it("keeps a change request whose forge reports no checks", () => { + const items = select({ prHint: { ...PR_HINT, checksStatus: undefined } }); + expect(kinds(items)).toEqual(["host", "changeRequest", "services"]); + }); +}); diff --git a/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts new file mode 100644 index 00000000000..98a52fcfc28 --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts @@ -0,0 +1,61 @@ +import type { PrHint } from "@/git/pr-hint"; +import type { SidebarChecksDisplay } from "@/components/sidebar/display-preferences/checks-display"; +import type { SidebarRowItems } from "@/components/sidebar/display-preferences/row-items"; +import { selectCheckSummary, type CheckSummary } from "./check-summary"; +import type { WorkspaceServiceSummary } from "./service-summary"; + +/** + * What ends up on the line under a workspace title, in the order it is read: where the + * workspace lives, what change it belongs to, whether that change is passing, and what it is + * running. Identity first, then the work, then the work's state. + */ +export type MetaRowItem = + | { kind: "host" } + | { kind: "changeRequest"; hint: PrHint } + | { kind: "checks"; summary: CheckSummary; label: boolean } + | { kind: "services"; summary: WorkspaceServiceSummary }; + +/** + * Which peers a row should draw, given what it knows and what the user left switched on. + * + * Kept out of the component because this — not the markup — is the part with rules in it: every + * toggle answers for itself, so a row can end up showing checks with no change request beside + * them, and CI resolves from the hint even when the hint itself is not drawn. + * + * The host is filtered upstream, where the badge map is built: a host that should show nothing + * has no badge to hand down, so by the time a row sees one it is meant to be drawn. + */ +export function selectMetaRowItems(input: { + hasHostBadge: boolean; + prHint: PrHint | null; + serviceSummary: WorkspaceServiceSummary | null; + visible: SidebarRowItems; + checksDisplay: SidebarChecksDisplay; +}): MetaRowItem[] { + const { hasHostBadge, prHint, serviceSummary, visible, checksDisplay } = input; + const items: MetaRowItem[] = []; + + if (hasHostBadge) { + items.push({ kind: "host" }); + } + if (prHint && visible.changeRequest) { + items.push({ kind: "changeRequest", hint: prHint }); + } + + // Independent of the change request, even though checks are read off one. Tying them together + // meant the checks setting could sit on a value while nothing was drawn, which is a control that + // lies about its own state. Showing checks without the change request beside them is the + // stranger combination, but it is the one you asked for and it is what you get. + if (checksDisplay !== "none") { + const summary = selectCheckSummary(prHint); + if (summary) { + items.push({ kind: "checks", summary, label: checksDisplay === "iconAndText" }); + } + } + + if (serviceSummary && visible.services) { + items.push({ kind: "services", summary: serviceSummary }); + } + + return items; +} diff --git a/packages/app/src/components/sidebar/workspace-meta-row/service-summary.test.ts b/packages/app/src/components/sidebar/workspace-meta-row/service-summary.test.ts new file mode 100644 index 00000000000..46147dae6e3 --- /dev/null +++ b/packages/app/src/components/sidebar/workspace-meta-row/service-summary.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { selectWorkspaceServiceSummary, workspaceServiceLabelKey } from "./service-summary"; + +type Script = SidebarWorkspaceEntry["scripts"][number]; + +function script(overrides: Partial', + }); + this.responses.set("/index.bundle", { status: 200, body: "compiled bundle" }); } async close(): Promise { @@ -63,3 +75,12 @@ test("Metro readiness rejects another HTTP listener on the selected port", async undefined, ); }); + +test("Metro warmup compiles the document's same-origin scripts before tests start", async () => { + endpoint = await MetroPort.listen(); + endpoint.serveWarmableDocument(); + + await warmMetro(endpoint.port); + + expect(endpoint.requests).toEqual(["/", "/index.bundle"]); +}); diff --git a/packages/app/src/file-explorer/read-result.test.ts b/packages/app/src/file-explorer/read-result.test.ts new file mode 100644 index 00000000000..fa5d3bb882e --- /dev/null +++ b/packages/app/src/file-explorer/read-result.test.ts @@ -0,0 +1,34 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import { describe, expect, it } from "vitest"; +import { explorerFileFromReadResult } from "./read-result"; + +function textRead(bytes: Uint8Array): FileReadResult { + return { + bytes, + mime: "text/plain", + size: bytes.byteLength, + path: "notes.txt", + kind: "text", + modifiedAt: "2026-07-21T00:00:00.000Z", + }; +} + +describe("explorerFileFromReadResult", () => { + it("records and hides a leading UTF-8 BOM", () => { + const file = explorerFileFromReadResult( + textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])), + ); + + expect(file).toMatchObject({ content: "hi", hasBom: true }); + }); + + it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => { + const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi"))); + const embedded = explorerFileFromReadResult( + textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])), + ); + + expect(plain.hasBom).toBe(false); + expect(embedded.hasBom).toBe(false); + }); +}); diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts index 693e0a4e144..2e36add9593 100644 --- a/packages/app/src/file-explorer/read-result.ts +++ b/packages/app/src/file-explorer/read-result.ts @@ -8,8 +8,14 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { kind: file.kind, encoding: isText ? "utf-8" : "none", content: isText ? new TextDecoder().decode(file.bytes) : undefined, + hasBom: isText && hasUtf8Bom(file.bytes), mimeType: file.mime, size: file.size, modifiedAt: file.modifiedAt, + revision: file.revision, }; } + +function hasUtf8Bom(bytes: Uint8Array): boolean { + return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; +} diff --git a/packages/app/src/file-explorer/tree.test.ts b/packages/app/src/file-explorer/tree.test.ts new file mode 100644 index 00000000000..e3df026c0d0 --- /dev/null +++ b/packages/app/src/file-explorer/tree.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import type { ExplorerEntry } from "@/stores/session-store"; +import { + MAX_AUTO_EXPANDED_DIRECTORY_DEPTH, + flattenExplorerTree, + reconcileRestoredExpandedPaths, + restoreExpandedDirectories, + setExpandedDirectoryPath, + showHiddenFilesAndRestoreExpandedDirectories, +} from "./tree"; + +function makeDirectoryEntry(name: string, path: string): ExplorerEntry { + return { + name, + path, + kind: "directory", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("file explorer tree", () => { + it("flattens a deeply expanded tree without consuming the call stack", () => { + const depth = 10_000; + const directories = new Map(); + const expandedPaths = new Set(["."]); + let parentPath = "."; + + for (let index = 1; index <= depth; index += 1) { + const childPath = `directory-${index}`; + directories.set(parentPath, { + path: parentPath, + entries: [makeDirectoryEntry(childPath, childPath)], + }); + expandedPaths.add(childPath); + parentPath = childPath; + } + directories.set(parentPath, { path: parentPath, entries: [] }); + + const rows = flattenExplorerTree({ + directories, + expandedPaths, + sortOption: "name", + showHiddenFiles: true, + }); + + expect(rows).toHaveLength(depth); + expect(rows[0]).toEqual({ + entry: makeDirectoryEntry("directory-1", "directory-1"), + depth: 0, + }); + expect(rows.at(-1)).toEqual({ + entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`), + depth: depth - 1, + }); + }); + + it("flattens a large expanded directory without spreading its rows into the parent", () => { + const fileCount = 150_000; + const files = Array.from( + { length: fileCount }, + (_, index): ExplorerEntry => ({ + name: `file-${index.toString().padStart(6, "0")}`, + path: `generated/file-${index}`, + kind: "file", + size: index, + modifiedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const child = makeDirectoryEntry("generated", "generated"); + const directories = new Map([ + [".", { path: ".", entries: [child] }], + ["generated", { path: "generated", entries: files }], + ]); + + const rows = flattenExplorerTree({ + directories, + expandedPaths: new Set([".", "generated"]), + sortOption: "name", + showHiddenFiles: true, + }); + + expect(rows).toHaveLength(fileCount + 1); + expect(rows[0]).toEqual({ entry: child, depth: 0 }); + expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 }); + }); + + it("restores five rendered directory levels rather than counting path segments", async () => { + const paths = [ + "generated/cache/level-1", + "generated/cache/level-1/level-2", + "generated/cache/level-1/level-2/level-3", + "generated/cache/level-1/level-2/level-3/level-4", + "generated/cache/level-1/level-2/level-3/level-4/level-5", + "generated/cache/level-1/level-2/level-3/level-4/level-5/level-6", + ]; + const directories = new Map(); + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry("level-1", paths[0])], + }; + directories.set(".", rootDirectory); + for (let index = 0; index < paths.length - 1; index += 1) { + directories.set(paths[index], { + path: paths[index], + entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])], + }); + } + + const requestedPaths: string[] = []; + const expandedPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set(paths), + showHiddenFiles: true, + requestDirectoryListing: async (path) => { + requestedPaths.push(path); + return directories.get(path) ?? null; + }, + }); + + expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5); + expect(requestedPaths).toEqual(paths.slice(0, 5)); + expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]); + }); + + it("does not restore persisted descendants beneath a collapsed rendered directory", async () => { + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry("parent", "parent")], + }; + const requestedPaths: string[] = []; + + const expandedPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]), + showHiddenFiles: true, + requestDirectoryListing: async (path) => { + requestedPaths.push(path); + return null; + }, + }); + + expect(requestedPaths).toEqual([]); + expect(expandedPaths).toEqual(["."]); + }); + + it("preserves expansion changes made while persisted directories are restoring", () => { + const paths = reconcileRestoredExpandedPaths({ + persistedExpandedPaths: new Set([".", "parent", "parent/child"]), + currentExpandedPaths: new Set([".", "parent/child", "manual"]), + restoredExpandedPaths: [".", "parent"], + }); + + expect(paths).toEqual([".", "manual"]); + }); + + it("applies a directory click to the latest restored expansion paths", () => { + const expanded = setExpandedDirectoryPath({ + currentExpandedPaths: [".", "restored"], + directoryPath: "manual", + expanded: true, + }); + const collapsed = setExpandedDirectoryPath({ + currentExpandedPaths: expanded, + directoryPath: "manual", + expanded: false, + }); + + expect(expanded).toEqual([".", "restored", "manual"]); + expect(collapsed).toEqual([".", "restored"]); + }); + + it("shows hidden files before waiting for expanded directories to restore", async () => { + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry(".hidden", ".hidden")], + }; + let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void; + const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => { + resolveDirectory = resolve; + }); + let hiddenFilesAreShown = false; + + const restoration = showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set([".hidden"]), + showHiddenFiles: () => { + hiddenFilesAreShown = true; + }, + requestDirectoryListing: () => directoryListing, + }); + + expect(hiddenFilesAreShown).toBe(true); + resolveDirectory({ path: ".hidden", entries: [] }); + await expect(restoration).resolves.toEqual([".", ".hidden"]); + }); +}); diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts new file mode 100644 index 00000000000..472c3d70754 --- /dev/null +++ b/packages/app/src/file-explorer/tree.ts @@ -0,0 +1,203 @@ +import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; +import type { SortOption } from "@/stores/panel-store/state"; +import { filterVisibleExplorerEntries } from "./visibility"; + +export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; + +export interface ExplorerTreeRow { + entry: ExplorerEntry; + depth: number; +} + +interface FlattenExplorerTreeInput { + directories: ReadonlyMap; + expandedPaths: ReadonlySet; + sortOption: SortOption; + showHiddenFiles: boolean; +} + +interface RestoreExpandedDirectoriesInput { + rootDirectory: ExplorerDirectory; + persistedExpandedPaths: ReadonlySet; + showHiddenFiles: boolean; + requestDirectoryListing: (path: string) => Promise; +} + +interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< + RestoreExpandedDirectoriesInput, + "showHiddenFiles" +> { + showHiddenFiles: () => void; +} + +interface ReconcileRestoredExpandedPathsInput { + persistedExpandedPaths: ReadonlySet; + currentExpandedPaths: ReadonlySet; + restoredExpandedPaths: string[]; +} + +interface SetExpandedDirectoryPathInput { + currentExpandedPaths: readonly string[]; + directoryPath: string; + expanded: boolean; +} + +export function flattenExplorerTree({ + directories, + expandedPaths, + sortOption, + showHiddenFiles, +}: FlattenExplorerTreeInput): ExplorerTreeRow[] { + const root = directories.get("."); + if (!root) { + return []; + } + + const rows: ExplorerTreeRow[] = []; + const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed(); + + while (pending.length > 0) { + const row = pending.pop(); + if (!row) { + break; + } + rows.push(row); + + const entry = row.entry; + if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) { + continue; + } + const childDirectory = directories.get(entry.path); + if (!childDirectory) { + continue; + } + const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles); + for (let index = childRows.length - 1; index >= 0; index -= 1) { + pending.push(childRows[index]); + } + } + + return rows; +} + +export async function restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing, +}: RestoreExpandedDirectoriesInput): Promise { + const restoredPaths = ["."]; + const restoredPathSet = new Set(restoredPaths); + let parentDirectories = [rootDirectory]; + + for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) { + const pathsToRequest: string[] = []; + for (const directory of parentDirectories) { + const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles); + for (const entry of entries) { + const isPersistedExpandedDirectory = + entry.kind === "directory" && persistedExpandedPaths.has(entry.path); + if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) { + pathsToRequest.push(entry.path); + restoredPathSet.add(entry.path); + } + } + } + if (pathsToRequest.length === 0) { + break; + } + + const requestedDirectories = await Promise.all( + pathsToRequest.map((path) => requestDirectoryListing(path)), + ); + parentDirectories = []; + for (const directory of requestedDirectories) { + if (!directory) { + continue; + } + restoredPaths.push(directory.path); + parentDirectories.push(directory); + } + } + + return restoredPaths; +} + +export function showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing, +}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { + showHiddenFiles(); + return restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles: true, + requestDirectoryListing, + }); +} + +export function reconcileRestoredExpandedPaths({ + persistedExpandedPaths, + currentExpandedPaths, + restoredExpandedPaths, +}: ReconcileRestoredExpandedPathsInput): string[] { + const reconciledPaths = new Set(restoredExpandedPaths); + + for (const path of persistedExpandedPaths) { + if (!currentExpandedPaths.has(path)) { + reconciledPaths.delete(path); + } + } + for (const path of currentExpandedPaths) { + if (!persistedExpandedPaths.has(path)) { + reconciledPaths.add(path); + } + } + + return Array.from(reconciledPaths); +} + +export function setExpandedDirectoryPath({ + currentExpandedPaths, + directoryPath, + expanded, +}: SetExpandedDirectoryPathInput): string[] { + const nextPaths = new Set(currentExpandedPaths); + if (expanded) { + nextPaths.add(directoryPath); + } else { + nextPaths.delete(directoryPath); + } + return Array.from(nextPaths); +} + +function rowsForDirectory( + directory: ExplorerDirectory, + depth: number, + sortOption: SortOption, + showHiddenFiles: boolean, +): ExplorerTreeRow[] { + const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles); + const sortedEntries = sortExplorerEntries(visibleEntries, sortOption); + return sortedEntries.map((entry) => ({ entry, depth })); +} + +function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { + const sorted = [...entries]; + sorted.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === "directory" ? -1 : 1; + } + switch (sortOption) { + case "name": + return a.name.localeCompare(b.name); + case "modified": + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + case "size": + return b.size - a.size; + } + }); + return sorted; +} diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx index e4574eacae6..b674cc200e8 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { SegmentedControl } from "@/components/ui/segmented-control"; import type { Theme } from "@/styles/theme"; -import { FileConflictAlert } from "./conflict-alert"; +import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; import type { FileEditorStatus } from "./editor/model"; const ThemedSpinner = withUnistyles(LoadingSpinner); @@ -18,9 +18,7 @@ export function FilePanelBar({ editorStatus, cursor, vimMode, - conflictUnavailable, - onOverwrite, - onReload, + conflict, }: { size: number; lineCount?: number; @@ -29,12 +27,10 @@ export function FilePanelBar({ editorStatus?: FileEditorStatus; cursor?: { line: number; column: number }; vimMode?: string | null; - conflictUnavailable?: boolean; - onOverwrite?(): void; - onReload?(): void; + conflict?: FileConflictAlertState; }) { const { t } = useTranslation(); - const markdownModes = [ + const previewModes = [ { value: "preview" as const, label: t("panels.file.editor.preview"), @@ -84,9 +80,6 @@ export function FilePanelBar({ {editorStatus === "error" ? ( {t("panels.file.editor.saveFailed")} ) : null} - {editorStatus === "conflict" ? ( - {t("panels.file.editor.changedOnDisk")} - ) : null} {vimMode ? ( ) : null} - {editorStatus === "conflict" && onOverwrite && onReload ? ( - - - - ) : null} + {conflict ? : null} ); } @@ -137,8 +122,6 @@ const styles = StyleSheet.create((theme) => ({ chrome: { flexShrink: 0, backgroundColor: theme.colors.surface1, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, }, row: { minHeight: 32, @@ -146,6 +129,8 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[3], paddingHorizontal: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, }, metadata: { flex: 1, @@ -174,5 +159,4 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.xs, }, - notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] }, })); diff --git a/packages/app/src/file-pane/conflict-alert.tsx b/packages/app/src/file-pane/conflict-alert.tsx index 3babdcefb43..106e16a8f2b 100644 --- a/packages/app/src/file-pane/conflict-alert.tsx +++ b/packages/app/src/file-pane/conflict-alert.tsx @@ -1,34 +1,86 @@ -import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { AlertTriangle } from "lucide-react-native"; import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; -export function FileConflictAlert({ - unavailable, - onOverwrite, - onReload, -}: { - unavailable: boolean; - onOverwrite(): void; - onReload(): void; -}) { +const ThemedAlertTriangle = withUnistyles(AlertTriangle); +const warningIconMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); + +export type FileConflictAlertState = + | { kind: "changed"; canOverwrite: boolean; onReload(): void; onOverwrite(): void } + | { kind: "deleted" } + | { kind: "checkFailed"; retrying: boolean; onRetry(): void }; + +export function FileConflictAlert({ state }: { state: FileConflictAlertState }) { const { t } = useTranslation(); + let title = t("panels.file.editor.changedOnDisk"); + if (state.kind === "deleted") title = t("panels.file.editor.deletedTitle"); + else if (state.kind === "checkFailed") title = t("panels.file.editor.checkFailedTitle"); + let description: string | undefined; + if (state.kind !== "changed") description = t("panels.file.editor.preservedDescription"); + else if (state.canOverwrite) description = t("panels.file.editor.conflictDescription"); + return ( - - - - + + + + {title} + {description ? {description} : null} + + {state.kind !== "deleted" ? ( + + {state.kind === "changed" && state.canOverwrite ? ( + + ) : null} + {state.kind === "changed" ? ( + + ) : null} + {state.kind === "checkFailed" ? ( + + ) : null} + + ) : null} + ); } + +const styles = StyleSheet.create((theme) => ({ + container: { + minHeight: 48, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.palette.amber[500], + backgroundColor: theme.colors.surface1, + }, + message: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + title: { + color: theme.colors.palette.amber[500], + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + actions: { + flexShrink: 0, + flexDirection: "row", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts index 9f165527751..e2f6ea4dbd1 100644 --- a/packages/app/src/file-pane/editor/model.test.ts +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -2,9 +2,12 @@ import { describe, expect, test } from "vitest"; import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; import { FileEditorModel, + getFileConflictCallout, type FileEditorClock, type FileEditorFile, + type FileEditorObservation, type FileEditorSession, + type FileObservationSource, } from "./model"; class TestClock implements FileEditorClock { @@ -37,10 +40,6 @@ class FileSession implements FileEditorSession { this.file = file; } - async read(): Promise { - return this.file; - } - async write(input: { content: string; expectedModifiedAt: string; @@ -70,6 +69,34 @@ class FileSession implements FileEditorSession { } } +class ObservationSource implements FileObservationSource { + observation: FileEditorObservation | null; + refreshes = 0; + private readonly listeners = new Set<() => void>(); + + constructor(observation: FileEditorObservation | null) { + this.observation = observation; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getObservation(): FileEditorObservation | null { + return this.observation; + } + + refresh = (): void => { + this.refreshes += 1; + }; + + emit(observation: FileEditorObservation): void { + this.observation = observation; + for (const listener of this.listeners) listener(); + } +} + function ready( modifiedAt = "2026-07-18T00:00:00.000Z", size = 3, @@ -77,13 +104,30 @@ function ready( return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt }; } -function makeModel() { - const file = { content: "one", version: ready() as Extract }; +interface MakeModelInput { + content?: string; + hasBom?: boolean; +} + +function makeModel(input: MakeModelInput = {}) { + const file = { + content: input.content ?? "one", + hasBom: input.hasBom ?? false, + version: ready() as Extract, + }; const session = new FileSession(file); const clock = new TestClock(); return { model: new FileEditorModel({ file, session, clock }), session, clock }; } +function observeFile(model: FileEditorModel, file: FileEditorFile): void { + model.receiveFileObservation({ status: "ready", file }); +} + +function observeVersion(model: FileEditorModel, version: FileEditorObservation): void { + model.receiveFileObservation(version); +} + describe("FileEditorModel", () => { test("tracks whether the current buffer differs from persisted content", async () => { const { model } = makeModel(); @@ -102,11 +146,194 @@ describe("FileEditorModel", () => { test("adopts a precise revision for otherwise unchanged initial metadata", () => { const { model } = makeModel(); - model.receiveFileVersion({ ...ready(), revision: "precise-revision" }); + observeFile(model, { + content: "one", + hasBom: false, + version: { ...ready(), revision: "precise-revision" }, + }); expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" }); }); + test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => { + const { model, session } = makeModel(); + model.edit("local"); + observeFile(model, { + content: "one", + hasBom: false, + version: { ...ready(), revision: "replacement-revision" }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" }); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "local", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + expectedRevision: "replacement-revision", + }, + ]); + }); + + test("adopts an external BOM change before saving a dirty buffer", async () => { + const { model, session } = makeModel(); + model.edit("local"); + observeFile(model, { + content: "one", + hasBom: true, + version: { ...ready(), revision: "replacement-revision" }, + }); + + await model.save(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFlocal", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + expectedRevision: "replacement-revision", + }, + ]); + }); + + test("conflicts when a same-content observation changes the BOM during a save", async () => { + const { model, session } = makeModel(); + session.holdNextWrite(); + model.edit("saved"); + const save = model.save(); + observeFile(model, { + content: "saved", + hasBom: true, + version: ready("2026-07-18T00:00:02.000Z", 6), + }); + session.finishHeldWrite({ + status: "written", + modifiedAt: "2026-07-18T00:00:01.000Z", + size: 5, + }); + + await save; + + expect(model.getSnapshot()).toMatchObject({ + status: "conflict", + observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, + }); + }); + + test("ignores a settled observation that was already consumed", () => { + const { model } = makeModel(); + const observation: FileEditorObservation = { + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }; + let emissions = 0; + model.subscribe(() => { + emissions += 1; + }); + model.receiveFileObservation(observation); + const emissionsAfterFirstDelivery = emissions; + + model.receiveFileObservation(observation); + + expect(emissions).toBe(emissionsAfterFirstDelivery); + }); + + test("does not replay the pre-save observation while its refresh is pending", async () => { + const { model } = makeModel(); + const observation: FileEditorObservation = { + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }; + model.receiveFileObservation(observation); + model.edit("saved"); + await model.save(); + + model.receiveFileObservation(observation); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" }); + }); + + test("does not reload stale bytes after a write conflict", async () => { + const { model, session } = makeModel(); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 8), + }; + model.edit("important local work"); + await model.save(); + + await model.reload(); + + expect(model.getSnapshot()).toMatchObject({ + status: "conflict", + content: "important local work", + observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, + }); + }); + + test("reloads a write conflict only after refreshed bytes arrive", async () => { + const { model, session } = makeModel(); + const source = new ObservationSource({ + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }); + model.connectFileObservations(source); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 4), + }; + model.edit("local"); + await model.save(); + + await model.reload(); + expect(source.refreshes).toBe(1); + expect(model.getSnapshot().content).toBe("local"); + source.emit({ + status: "ready", + file: { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); + }); + + test("abandons a deferred reload when its refresh fails", async () => { + const { model, session } = makeModel(); + const source = new ObservationSource({ + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }); + model.connectFileObservations(source); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 4), + }; + model.edit("local"); + await model.save(); + await model.reload(); + source.emit({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "File unavailable.", + }); + model.edit("new local work"); + + source.emit({ + status: "ready", + file: { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:03.000Z", 4), + }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" }); + }); + test("keeps a newer edit modified when an older save finishes", async () => { const { model, session } = makeModel(); session.holdNextWrite(); @@ -142,32 +369,89 @@ describe("FileEditorModel", () => { expect(model.getSnapshot().status).toBe("clean"); }); + test("keeps CRLF content in file form", async () => { + const { model, session } = makeModel({ content: "one\r\ntwo\r\n" }); + + expect(model.getSnapshot()).toMatchObject({ + content: "one\r\ntwo\r\n", + lineSeparator: "\r\n", + }); + model.edit("one\r\ntwo\r\nthree\r\n"); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "one\r\ntwo\r\nthree\r\n", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + }, + ]); + }); + + test("restores a UTF-8 BOM before writing a CRLF file", async () => { + const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); + + model.edit("saved\r\n"); + await model.save(); + model.edit("saved again\r\n"); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFsaved\r\n", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + }, + { + content: "\uFEFFsaved again\r\n", + expectedModifiedAt: "2026-07-18T00:00:01.000Z", + }, + ]); + }); + + test("uses the first line separator when a file mixes styles", () => { + const { model } = makeModel({ content: "one\r\ntwo\nthree\r" }); + + expect(model.getSnapshot().lineSeparator).toBe("\r\n"); + }); + test("reloads a clean editor when the disk version changes", async () => { const { model, session } = makeModel(); session.file = { content: "external", + hasBom: false, version: ready("2026-07-18T00:00:02.000Z", 8) as Extract, }; - model.receiveFileVersion(session.file.version); - await Promise.resolve(); + observeFile(model, session.file); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" }); }); - test("coalesces consecutive clean disk updates onto the latest reload", async () => { - const { model, session } = makeModel(); - const reads: Array<(file: FileEditorFile) => void> = []; - session.read = () => new Promise((resolve) => reads.push(resolve)); + test("adopts the format from a clean remote refresh", async () => { + const { model, session } = makeModel({ content: "local\r\n", hasBom: true }); + session.file = { + content: "remote\n", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 7) as Extract, + }; + + observeFile(model, session.file); + expect(model.getSnapshot().lineSeparator).toBe("\n"); + model.edit("saved\n"); + await model.save(); + + expect(session.writes.at(-1)).toEqual({ + content: "saved\n", + expectedModifiedAt: "2026-07-18T00:00:02.000Z", + }); + }); + + test("applies consecutive clean disk observations", () => { + const { model } = makeModel(); const firstVersion = ready("2026-07-18T00:00:02.000Z", 5); const latestVersion = ready("2026-07-18T00:00:03.000Z", 6); - model.receiveFileVersion(firstVersion); - model.receiveFileVersion(latestVersion); - reads[0]?.({ content: "first", version: firstVersion }); - await Promise.resolve(); - reads[1]?.({ content: "latest", version: latestVersion }); - await Promise.resolve(); + observeFile(model, { content: "first", hasBom: false, version: firstVersion }); + observeFile(model, { content: "latest", hasBom: false, version: latestVersion }); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" }); }); @@ -175,7 +459,11 @@ describe("FileEditorModel", () => { test("preserves a dirty buffer and overwrites against the newest disk revision", async () => { const { model, session } = makeModel(); model.edit("local"); - model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + observeFile(model, { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }); expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" }); await model.overwrite(); @@ -186,6 +474,25 @@ describe("FileEditorModel", () => { expect(model.getSnapshot().status).toBe("clean"); }); + test("keeps the local CRLF and BOM when overwriting a conflict", async () => { + const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); + model.edit("local\r\n"); + observeFile(model, { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }); + + await model.overwrite(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFlocal\r\n", + expectedModifiedAt: "2026-07-18T00:00:02.000Z", + }, + ]); + }); + test("reload discards a conflicted local buffer for the disk contents", async () => { const { model, session } = makeModel(); model.edit("local"); @@ -193,14 +500,34 @@ describe("FileEditorModel", () => { FileVersion, { status: "ready" } >; - session.file = { content: "disk", version: diskVersion }; - model.receiveFileVersion(diskVersion); + session.file = { content: "disk", hasBom: false, version: diskVersion }; + observeFile(model, session.file); await model.reload(); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); }); + test("adopts the remote format when reloading a conflict", async () => { + const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); + model.edit("local\r\n"); + const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract< + FileVersion, + { status: "ready" } + >; + session.file = { content: "disk\n", hasBom: false, version: diskVersion }; + observeFile(model, session.file); + + await model.reload(); + model.edit("saved\n"); + await model.save(); + + expect(session.writes.at(-1)).toEqual({ + content: "saved\n", + expectedModifiedAt: "2026-07-18T00:00:02.000Z", + }); + }); + test("reports failed saves without losing the local buffer", async () => { const { model, session } = makeModel(); session.nextWrite = new Error("disk full"); @@ -218,7 +545,7 @@ describe("FileEditorModel", () => { test("a deletion conflicts with local changes and stops autosave", () => { const { model, session, clock } = makeModel(); model.edit("local"); - model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" }); + observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" }); clock.fire(); @@ -226,6 +553,45 @@ describe("FileEditorModel", () => { expect(session.writes).toEqual([]); }); + test("clears a transient check error when the recovered file is unchanged", () => { + const { model } = makeModel(); + observeVersion(model, { + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + + observeFile(model, { content: "one", hasBom: false, version: ready() }); + + expect(model.getSnapshot()).toMatchObject({ + status: "clean", + modified: false, + observedVersion: { status: "ready" }, + error: null, + }); + }); + + test("resumes autosave when a dirty file recovers unchanged from a check error", async () => { + const { model, session, clock } = makeModel(); + model.edit("local"); + observeVersion(model, { + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + + observeFile(model, { content: "one", hasBom: false, version: ready() }); + clock.fire(); + await Promise.resolve(); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); + expect(session.writes).toEqual([ + { content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" }, + ]); + }); + test("dispose cancels pending autosave", () => { const { model, session, clock } = makeModel(); model.edit("local"); @@ -249,4 +615,38 @@ describe("FileEditorModel", () => { await Promise.resolve(); expect(session.writes).toHaveLength(1); }); + + test("maps conflict versions to one exhaustive callout state", () => { + const { model } = makeModel(); + const snapshot = model.getSnapshot(); + + expect(getFileConflictCallout(snapshot)).toBeNull(); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + modified: true, + observedVersion: ready("2026-07-18T00:00:01.000Z"), + }), + ).toEqual({ kind: "changed", canOverwrite: true }); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" }, + }), + ).toEqual({ kind: "deleted" }); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + observedVersion: { + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "unreadable", + }, + }), + ).toEqual({ kind: "checkFailed" }); + }); }); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts index a2b081d7bc2..6207e288907 100644 --- a/packages/app/src/file-pane/editor/model.ts +++ b/packages/app/src/file-pane/editor/model.ts @@ -1,23 +1,30 @@ import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; -export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error"; +export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error"; +export type FileLineSeparator = "\n" | "\r\n" | "\r"; export interface FileEditorSnapshot { status: FileEditorStatus; content: string; + lineSeparator: FileLineSeparator; modified: boolean; version: FileVersion; observedVersion: FileVersion; error: string | null; } +export type FileConflictCallout = + | { kind: "changed"; canOverwrite: boolean } + | { kind: "deleted" } + | { kind: "checkFailed" }; + export interface FileEditorFile { content: string; + hasBom: boolean; version: Extract; } export interface FileEditorSession { - read(): Promise; write(input: { content: string; expectedModifiedAt: string; @@ -25,6 +32,18 @@ export interface FileEditorSession { }): Promise; } +export type FileEditorObservation = + | { status: "ready"; file: FileEditorFile } + | Extract; + +export interface FileObservationSource { + subscribe(listener: () => void): () => void; + getObservation(): FileEditorObservation | null; + refresh(): void; +} + +type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion }; + export interface FileEditorClock { setTimeout(callback: () => void, delayMs: number): ReturnType; clearTimeout(handle: ReturnType): void; @@ -47,8 +66,14 @@ export class FileEditorModel { private autosave: ReturnType | null = null; private saveSequence = 0; private disposed = false; - private observedWhileSaving: FileVersion | null = null; + private observedWhileSaving: FileEditorObservation | null = null; + private observed: ObservedDiskState; + private lastReceivedObservation: FileEditorObservation | null = null; + private refreshObservation: (() => void) | null = null; + private reloadRequested = false; private persistedContent: string; + private hasBom: boolean; + private unsubscribeObservationSource: (() => void) | null = null; constructor(input: { file: FileEditorFile; @@ -58,9 +83,12 @@ export class FileEditorModel { this.session = input.session; this.clock = input.clock ?? systemClock; this.persistedContent = input.file.content; + this.hasBom = input.file.hasBom; + this.observed = { status: "ready", file: input.file }; this.snapshot = { status: "clean", content: input.file.content, + lineSeparator: detectLineSeparator(input.file.content), modified: false, version: input.file.version, observedVersion: input.file.version, @@ -75,11 +103,29 @@ export class FileEditorModel { getSnapshot = (): FileEditorSnapshot => this.snapshot; + connectFileObservations(source: FileObservationSource): void { + this.disconnectFileObservations(); + this.refreshObservation = source.refresh; + const receiveObservation = () => { + const observation = source.getObservation(); + if (observation) this.receiveFileObservation(observation); + }; + this.unsubscribeObservationSource = source.subscribe(receiveObservation); + receiveObservation(); + } + + disconnectFileObservations(): void { + this.unsubscribeObservationSource?.(); + this.unsubscribeObservationSource = null; + this.refreshObservation = null; + } + edit(content: string): void { if (this.disposed || content === this.snapshot.content) return; + this.reloadRequested = false; const modified = content !== this.persistedContent; let status: FileEditorStatus = modified ? "dirty" : "clean"; - if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") { + if (this.snapshot.status === "conflict") { status = "conflict"; } this.setSnapshot({ ...this.snapshot, status, content, modified, error: null }); @@ -98,33 +144,32 @@ export class FileEditorModel { await this.performWrite(this.snapshot.observedVersion); } - receiveFileVersion(version: FileVersion): void { - if (this.disposed) return; - if (sameVersion(version, this.snapshot.observedVersion)) { - if ( - version.status === "ready" && - this.snapshot.observedVersion.status === "ready" && - version.revision && - !this.snapshot.observedVersion.revision - ) { - this.setSnapshot({ - ...this.snapshot, - version: - this.snapshot.version.status === "ready" - ? { ...this.snapshot.version, revision: version.revision } - : this.snapshot.version, - observedVersion: version, - }); - } - return; - } + receiveFileObservation(observation: FileEditorObservation): void { + if (this.disposed || observation === this.lastReceivedObservation) return; + this.lastReceivedObservation = observation; + const version = observationVersion(observation); + this.observed = observation; this.setSnapshot({ ...this.snapshot, observedVersion: version }); if (this.snapshot.status === "saving") { - this.observedWhileSaving = version; + this.observedWhileSaving = observation; return; } - if (this.snapshot.status === "clean" || this.snapshot.status === "loading") { - void this.reloadFromDisk(version); + if (observation.status !== "ready") { + this.reloadRequested = false; + this.enterConflict(version); + return; + } + if (this.reloadRequested) { + this.reloadRequested = false; + this.applyFile(observation.file); + return; + } + if (observation.file.content === this.persistedContent) { + this.adoptUnchangedFile(observation.file); + return; + } + if (this.snapshot.status === "clean") { + this.applyFile(observation.file); return; } this.enterConflict(version); @@ -138,13 +183,20 @@ export class FileEditorModel { async reload(): Promise { if (this.disposed) return; - await this.reloadFromDisk(this.snapshot.observedVersion); + if (this.observed.status !== "ready") { + this.reloadRequested = true; + this.refreshObservation?.(); + return; + } + this.applyFile(this.observed.file); } dispose(): void { this.disposed = true; + this.reloadRequested = false; this.saveSequence += 1; this.clearAutosave(); + this.disconnectFileObservations(); this.listeners.clear(); } @@ -165,12 +217,14 @@ export class FileEditorModel { this.clearAutosave(); const sequence = ++this.saveSequence; const content = this.snapshot.content; + const hasBom = this.hasBom; this.observedWhileSaving = null; this.setSnapshot({ ...this.snapshot, status: "saving", error: null }); + const serializedContent = hasBom ? `\uFEFF${content}` : content; let result: FileWriteResult; try { result = await this.session.write({ - content, + content: serializedContent, expectedModifiedAt: expectedVersion.modifiedAt, expectedRevision: expectedVersion.revision, }); @@ -189,11 +243,12 @@ export class FileEditorModel { return; } if (result.status === "conflict") { + this.observed = { status: "unsettled", version: result.version }; this.enterConflict(result.version); return; } - const writtenVersion: FileVersion = { + const writtenVersion: Extract = { status: "ready", cwd: this.snapshot.version.cwd, path: this.snapshot.version.path, @@ -201,62 +256,56 @@ export class FileEditorModel { modifiedAt: result.modifiedAt, revision: result.revision, }; - const pending = this.observedWhileSaving; - this.observedWhileSaving = null; + const pending = this.takeObservedWhileSaving(); this.persistedContent = content; - if (pending && !sameVersion(pending, writtenVersion)) { + if (pending && !observationMatchesWrite(pending, content, hasBom)) { + const pendingVersion = observationVersion(pending); + this.observed = pending; this.setSnapshot({ ...this.snapshot, status: "conflict", modified: this.snapshot.content !== this.persistedContent, version: writtenVersion, - observedVersion: pending, + observedVersion: pendingVersion, error: null, }); return; } + const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion; + this.observed = pending ?? { status: "unsettled", version: writtenVersion }; const modified = this.snapshot.content !== this.persistedContent; this.setSnapshot({ ...this.snapshot, status: modified ? "dirty" : "clean", modified, - version: writtenVersion, - observedVersion: writtenVersion, + version: settledVersion, + observedVersion: settledVersion, error: null, }); if (modified) this.scheduleAutosave(); } - private async reloadFromDisk(version: FileVersion): Promise { + private applyFile(file: FileEditorFile): void { this.clearAutosave(); - if (version.status !== "ready") { - this.enterConflict(version); - return; - } - const sequence = ++this.saveSequence; - this.setSnapshot({ ...this.snapshot, status: "loading", error: null }); - try { - const file = await this.session.read(); - if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") { - return; - } - this.persistedContent = file.content; - this.setSnapshot({ - status: "clean", - content: file.content, - modified: false, - version: file.version, - observedVersion: file.version, - error: null, - }); - } catch (error) { - if (this.disposed || sequence !== this.saveSequence) return; - this.setSnapshot({ - ...this.snapshot, - status: "error", - error: error instanceof Error ? error.message : String(error), - }); - } + this.saveSequence += 1; + this.persistedContent = file.content; + this.hasBom = file.hasBom; + this.observed = { status: "ready", file }; + this.setSnapshot({ + status: "clean", + content: file.content, + lineSeparator: detectLineSeparator(file.content), + modified: false, + version: file.version, + observedVersion: file.version, + error: null, + }); + } + + private takeObservedWhileSaving(): FileEditorObservation | null { + const observation = this.observedWhileSaving; + this.observedWhileSaving = null; + return observation; } private enterConflict(version: FileVersion): void { @@ -270,6 +319,25 @@ export class FileEditorModel { }); } + private adoptUnchangedFile(file: FileEditorFile): void { + this.hasBom = file.hasBom; + this.observed = { status: "ready", file }; + const modified = this.snapshot.content !== this.persistedContent; + const recovering = this.snapshot.status === "conflict"; + let status = this.snapshot.status; + if (recovering) status = modified ? "dirty" : "clean"; + this.setSnapshot({ + ...this.snapshot, + status, + modified, + version: file.version, + observedVersion: file.version, + error: recovering ? null : this.snapshot.error, + }); + if (status === "dirty") this.scheduleAutosave(); + else this.clearAutosave(); + } + private scheduleAutosave(): void { this.clearAutosave(); this.autosave = this.clock.setTimeout(() => { @@ -290,13 +358,45 @@ export class FileEditorModel { } } -function sameVersion(left: FileVersion, right: FileVersion): boolean { - if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path) - return false; - if (left.status === "ready" && right.status === "ready") { - if (left.revision && right.revision) return left.revision === right.revision; - return left.modifiedAt === right.modifiedAt && left.size === right.size; +export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null { + if (snapshot.status !== "conflict") return null; + switch (snapshot.observedVersion.status) { + case "ready": + return { kind: "changed", canOverwrite: snapshot.modified }; + case "missing": + return { kind: "deleted" }; + case "error": + return { kind: "checkFailed" }; + default: + return assertNever(snapshot.observedVersion); + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected file version: ${JSON.stringify(value)}`); +} + +function detectLineSeparator(content: string): FileLineSeparator { + for (let index = 0; index < content.length; index += 1) { + const character = content.charCodeAt(index); + if (character === 10) return "\n"; + if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r"; } - if (left.status === "error" && right.status === "error") return left.error === right.error; - return true; + return "\n"; +} + +function observationVersion(observation: FileEditorObservation): FileVersion { + return observation.status === "ready" ? observation.file.version : observation; +} + +function observationMatchesWrite( + observation: FileEditorObservation, + content: string, + hasBom: boolean, +): boolean { + return ( + observation.status === "ready" && + observation.file.content === content && + observation.file.hasBom === hasBom + ); } diff --git a/packages/app/src/file-pane/editor/observation-source.test.ts b/packages/app/src/file-pane/editor/observation-source.test.ts new file mode 100644 index 00000000000..30a751b5c1a --- /dev/null +++ b/packages/app/src/file-pane/editor/observation-source.test.ts @@ -0,0 +1,60 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import { describe, expect, test } from "vitest"; +import type { LiveFileObservation } from "../live-file/model"; +import { createFileObservationSource, type LiveObservationSource } from "./observation-source"; + +class TestLiveObservationSource implements LiveObservationSource { + observation: LiveFileObservation | null; + + constructor(observation: LiveFileObservation | null) { + this.observation = observation; + } + + subscribe(): () => void { + return () => undefined; + } + + getObservation(): LiveFileObservation | null { + return this.observation; + } + + refresh(): void {} +} + +function readyObservation(): LiveFileObservation { + const file: FileReadResult = { + bytes: new TextEncoder().encode("one"), + mime: "text/plain", + size: 3, + path: "file.ts", + kind: "text", + modifiedAt: "2026-07-18T00:00:00.000Z", + revision: "revision-1", + }; + return { + status: "ready", + file, + version: { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + }; +} + +describe("createFileObservationSource", () => { + test("returns the same editor observation until the live observation changes", () => { + const liveFile = new TestLiveObservationSource(readyObservation()); + const source = createFileObservationSource(liveFile); + + const first = source.getObservation(); + const repeated = source.getObservation(); + + expect(repeated).toBe(first); + liveFile.observation = readyObservation(); + expect(source.getObservation()).not.toBe(first); + }); +}); diff --git a/packages/app/src/file-pane/editor/observation-source.ts b/packages/app/src/file-pane/editor/observation-source.ts new file mode 100644 index 00000000000..478c5d3e762 --- /dev/null +++ b/packages/app/src/file-pane/editor/observation-source.ts @@ -0,0 +1,50 @@ +import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import type { LiveFileObservation } from "../live-file/model"; +import type { FileEditorObservation, FileObservationSource } from "./model"; + +export interface LiveObservationSource { + subscribe(listener: () => void): () => void; + getObservation(): LiveFileObservation | null; + refresh(): void; +} + +export function createFileObservationSource( + liveFile: LiveObservationSource, +): FileObservationSource { + let liveObservation: LiveFileObservation | null = null; + let editorObservation: FileEditorObservation | null = null; + return { + subscribe: liveFile.subscribe, + refresh: liveFile.refresh, + getObservation() { + const nextLiveObservation = liveFile.getObservation(); + if (nextLiveObservation === liveObservation) return editorObservation; + liveObservation = nextLiveObservation; + editorObservation = fileEditorObservation(nextLiveObservation); + return editorObservation; + }, + }; +} + +function fileEditorObservation( + observation: LiveFileObservation | null, +): FileEditorObservation | null { + if (!observation || observation.status !== "ready") return observation; + const decodedFile = explorerFileFromReadResult(observation.file); + if (decodedFile.kind !== "text" || decodedFile.content === undefined) { + return { + status: "error", + cwd: observation.version.cwd, + path: observation.version.path, + error: "File is no longer text.", + }; + } + return { + status: "ready", + file: { + content: decodedFile.content, + hasBom: decodedFile.hasBom, + version: observation.version, + }, + }; +} diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index cc211c7a542..03ab27a7785 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -3,6 +3,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; import { getCM, vim } from "@replit/codemirror-vim"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; @@ -19,9 +20,14 @@ interface FileEditorViewProps { } const languageCompartment = new Compartment(); +const wrappingCompartment = new Compartment(); const themeCompartment = new Compartment(); const vimCompartment = new Compartment(); +function wrappingForFile(filename: string) { + return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : []; +} + export function FileEditorView({ model, filename, @@ -50,13 +56,15 @@ export function FileEditorView({ vimCompartment.of(values.vimEnabled ? vim() : []), ...editorBaseExtensions(() => void values.model.save()), languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []), + wrappingCompartment.of(wrappingForFile(values.filename)), themeCompartment.of(editorTheme(values.theme)), EditorView.updateListener.of((update) => { if ( update.docChanged && !update.transactions.some((tr) => tr.annotation(remoteUpdate)) ) { - values.model.edit(update.state.doc.toString()); + const { lineSeparator } = values.model.getSnapshot(); + values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator)); } if (update.selectionSet || update.docChanged) { const head = update.state.selection.main.head; @@ -77,10 +85,12 @@ export function FileEditorView({ useEffect(() => { const view = viewRef.current; - if (!view || view.state.doc.toString() === snapshot.content) return; - const head = Math.min(view.state.selection.main.head, snapshot.content.length); + if (!view) return; + const document = view.state.toText(snapshot.content); + if (view.state.doc.eq(document)) return; + const head = Math.min(view.state.selection.main.head, document.length); view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: snapshot.content }, + changes: { from: 0, to: view.state.doc.length, insert: document }, selection: { anchor: head }, annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)], }); @@ -101,7 +111,10 @@ export function FileEditorView({ useEffect(() => { viewRef.current?.dispatch({ - effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), + effects: [ + languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), + wrappingCompartment.reconfigure(wrappingForFile(filename)), + ], }); }, [filename]); diff --git a/packages/app/src/file-pane/html-preview-csp.test.ts b/packages/app/src/file-pane/html-preview-csp.test.ts new file mode 100644 index 00000000000..bbf3bbb745c --- /dev/null +++ b/packages/app/src/file-pane/html-preview-csp.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { withPreviewCsp } from "@/file-pane/html-preview-csp"; + +describe("withPreviewCsp", () => { + it("places the policy before the complete source document", () => { + const source = + " "; + const output = withPreviewCsp(source); + + expect(output).toMatch(/^")); + }); + + it("keeps the original document intact", () => { + const source = "

Visual plan

"; + + expect(withPreviewCsp(source)).toContain(source); + }); + + it("drops a leading BOM before appending the source", () => { + const output = withPreviewCsp("

Plan

"); + + expect(output.includes("")).toBe(false); + expect(output).toContain("

Plan

"); + }); + + it("refuses remote resources while allowing inline scripts and styles", () => { + const document = withPreviewCsp("

Plan

"); + + expect(document).toContain("default-src 'none'"); + expect(document).toContain("connect-src 'none'"); + expect(document).toContain("form-action 'none'"); + expect(document).toContain("base-uri 'none'"); + expect(document).toContain("frame-src 'none'"); + expect(document).toContain("object-src 'none'"); + expect(document).toContain("script-src 'unsafe-inline'"); + expect(document).toContain("style-src 'unsafe-inline'"); + expect(document).not.toMatch(/script-src[^;]*https:/); + expect(document).not.toMatch(/img-src[^;]*https:/); + expect(document).not.toMatch(/connect-src[^;]*https:/); + }); + + it("handles pathological source without parsing it", () => { + const source = `${"`, `` +// and ``, bogus-comment tokens like `` and ``, and the +// rule that a doctype closes at the first `>` in every state. Each of those rules +// cost a bug before it was right. +// +// So the prologue isn't found, it's supplied: our doctype, then the policy, then +// the file verbatim. The file's own doctype becomes a stray DOCTYPE token, which +// the parser ignores wherever it appears. Standards mode is guaranteed, the policy +// is always the first element and therefore always in the head, and no part of the +// document has to be parsed to place it. +const PROLOGUE = `${META}`; + +// Left where it is, a BOM would sit mid-document and render as a zero-width space. +const BOM = "\uFEFF"; + +export function withPreviewCsp(html: string): string { + return PROLOGUE + (html.startsWith(BOM) ? html.slice(BOM.length) : html); +} diff --git a/packages/app/src/file-pane/html-preview-navigation.test.ts b/packages/app/src/file-pane/html-preview-navigation.test.ts new file mode 100644 index 00000000000..e2029085af0 --- /dev/null +++ b/packages/app/src/file-pane/html-preview-navigation.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation"; + +describe("htmlPreviewNavigationKind", () => { + it("allows the WebView's initial document URLs", () => { + expect(htmlPreviewNavigationKind("about:blank")).toBe("initialDocument"); + expect(htmlPreviewNavigationKind("about:srcdoc")).toBe("initialDocument"); + expect(htmlPreviewNavigationKind("")).toBe("initialDocument"); + }); + + it("allows same-document fragment navigation", () => { + expect(htmlPreviewNavigationKind("about:blank#decision-table")).toBe("fragment"); + expect(htmlPreviewNavigationKind("about:srcdoc#decision-table")).toBe("fragment"); + }); + + it("blocks documents that could escape the injected policy", () => { + expect(htmlPreviewNavigationKind("https://example.com/leak")).toBe("blocked"); + expect( + htmlPreviewNavigationKind("data:text/html,"), + ).toBe("blocked"); + expect(htmlPreviewNavigationKind("blob:https://example.com/document-id")).toBe("blocked"); + }); +}); diff --git a/packages/app/src/file-pane/html-preview-navigation.ts b/packages/app/src/file-pane/html-preview-navigation.ts new file mode 100644 index 00000000000..fafd4e57935 --- /dev/null +++ b/packages/app/src/file-pane/html-preview-navigation.ts @@ -0,0 +1,9 @@ +export type HtmlPreviewNavigationKind = "initialDocument" | "fragment" | "blocked"; + +const INITIAL_DOCUMENT_URLS = new Set(["about:blank", "about:srcdoc", ""]); + +export function htmlPreviewNavigationKind(url: string): HtmlPreviewNavigationKind { + if (INITIAL_DOCUMENT_URLS.has(url)) return "initialDocument"; + if (url.startsWith("about:blank#") || url.startsWith("about:srcdoc#")) return "fragment"; + return "blocked"; +} diff --git a/packages/app/src/file-pane/html-preview.tsx b/packages/app/src/file-pane/html-preview.tsx new file mode 100644 index 00000000000..08ded111e73 --- /dev/null +++ b/packages/app/src/file-pane/html-preview.tsx @@ -0,0 +1,76 @@ +import { useCallback, useMemo, useRef } from "react"; +import { StyleSheet } from "react-native-unistyles"; +import { WebView } from "react-native-webview"; +import { withPreviewCsp } from "./html-preview-csp"; +import { htmlPreviewNavigationKind } from "./html-preview-navigation"; + +// A preview is a viewer, not a browser. Only the document Paseo hands the WebView +// loads; navigations the page attempts afterwards are refused, so a link, a +// `location.href` assignment, or a meta refresh cannot pull a remote page into the +// pane or leak the file through a URL. Storage and cache stay off so a page leaves +// nothing behind between opens. +// +// This guard is not absolute, and SECURITY.md says so rather than implying +// otherwise: the decision runs in app JS, and Android's WebView allows a +// navigation whose decision doesn't return in time. A stalled JS thread is +// therefore a window, which is why the CSP does the load-bearing work and this +// guard narrows what's left. +// +// `originWhitelist: ["*"]` is what makes that guarantee hold. react-native-webview +// checks the whitelist *before* calling onShouldStartLoadWithRequest and hands +// anything that fails it to `Linking.openURL` — so a narrow whitelist would route +// custom schemes straight to the system browser without this guard ever seeing +// them. Matching everything forces every scheme through the callback below. +const ORIGIN_WHITELIST = ["*"]; + +// Pinning the base URL is what makes the guard below sound. Android loads +// `source={{ html }}` through `loadDataWithBaseURL`, and a programmatic load is not +// reliably reported to onShouldStartLoadWithRequest — so the latch may still be +// unset when the page makes its first move. Naming an inert base means the only +// URLs that can pass as "initial" are inert ones. `data:text/html` must NOT be +// allowed here: a page could navigate itself to a data document of its own, which +// would arrive with no injected policy and a clean slate to egress from. +const BASE_URL = "about:blank"; + +export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) { + const document = useMemo(() => withPreviewCsp(html), [html]); + const source = useMemo(() => ({ html: document, baseUrl: BASE_URL }), [document]); + // Latched per document rather than once for the lifetime of the WebView: the + // file pane re-renders with new content on every live-file refresh, and each of + // those is a fresh initial load that has to be allowed through. + const loadedDocumentRef = useRef(null); + const allowOnlyInitialDocument = useCallback( + ({ url }: { url: string }) => { + const navigationKind = htmlPreviewNavigationKind(url); + if (navigationKind === "fragment") return true; + if (navigationKind === "blocked") return false; + if (loadedDocumentRef.current === document) return false; + loadedDocumentRef.current = document; + return true; + }, + [document], + ); + + return ( + + ); +} + +const styles = StyleSheet.create(() => ({ + webview: { + flex: 1, + backgroundColor: "white", + }, +})); diff --git a/packages/app/src/file-pane/html-preview.web.tsx b/packages/app/src/file-pane/html-preview.web.tsx new file mode 100644 index 00000000000..64d85807a3e --- /dev/null +++ b/packages/app/src/file-pane/html-preview.web.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { withPreviewCsp } from "./html-preview-csp"; + +// `allow-scripts` alone: the file gets an opaque origin, so a plan page can run +// its own scripts (Excalidraw, charts) but cannot reach the Paseo app's DOM, +// cookies, or storage, and cannot navigate the top window. Agent-written HTML is +// not trusted markup. No popup tokens — a preview is a viewer, not a browser, and +// escaping the sandbox to open one buys nothing for reading a local plan. The same +// isolation means storage APIs throw inside the frame; pages that want to persist +// state have to export. +// +// A sandboxed frame may still navigate *itself*, and nothing in CSP stops that +// (see html-preview-csp.ts). That is the one hole left on web, it is bounded to +// the page's own contents, and it is documented in SECURITY.md rather than papered +// over with a directive browsers ignore. +const SANDBOX = "allow-scripts"; + +const iframeStyle = { + flex: 1, + minHeight: 0, + border: "none", + backgroundColor: "white", +} as const; + +export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) { + const { t } = useTranslation(); + const document = useMemo(() => withPreviewCsp(html), [html]); + return ( +