Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 831dba9122 Add database query CLI tool for development 2026-03-25 20:39:08 +07:00
Mohamed Boudra 296f283d44 Eliminate full timeline memory load: route committed queries to DB 2026-03-25 20:39:08 +07:00
Mohamed Boudra f27c803bf8 fix(server): keep Claude model queries open 2026-03-25 20:39:08 +07:00
Mohamed Boudra ce8128910e Fix streaming double-dispatch: suppress raw chunks during provisional buffering 2026-03-25 20:39:08 +07:00
Mohamed Boudra c2d8cbdb2e Fix delete race: skip background persist for closed agents 2026-03-25 20:39:08 +07:00
Mohamed Boudra 05094fe9e1 Phase 5: remove legacy storage paths and dead compatibility code 2026-03-25 20:39:07 +07:00
Mohamed Boudra 6027c38f8c Phase 4c: skip provider-history rehydrate when DB timeline is available 2026-03-25 20:39:07 +07:00
Mohamed Boudra eae83a53d8 Phase 4b: timeline read authority cutover to DB 2026-03-25 20:39:07 +07:00
Mohamed Boudra 17dc2801db Phase 4a: durable timeline store adapter + dual-write 2026-03-25 20:39:07 +07:00
Mohamed Boudra 3e9fd00501 Phase 3b: agent snapshot DB cutover + interface extraction 2026-03-25 20:39:07 +07:00
Mohamed Boudra 307a6e85f9 Phase 3a: project/workspace DB cutover + legacy JSON import
Bootstrap now opens PGlite and uses DB-backed project/workspace
registries as the sole runtime authority. Legacy JSON files are
imported transactionally on first startup; restart is idempotent.

Key changes:
- bootstrap.ts: DB registries replace file-backed, DB lifecycle
  managed on startup/shutdown/error
- workspace-registry-bootstrap.ts: fixed FK write order (project
  before workspace), uses list() instead of existsOnDisk()
- New transactional import module with rollback safety
- Bootstrap smoke tests for import, idempotence, and reconciliation

Audit-verified: authority cutover (13/13 checks), boundary (10/10).
2026-03-25 20:39:07 +07:00
Mohamed Boudra 7f11c0c64c Phase 2b: DB-backed project and workspace registry adapters
Add DbProjectRegistry and DbWorkspaceRegistry implementing the existing
ProjectRegistry/WorkspaceRegistry interfaces against Drizzle/PGlite.
Real PGlite contract tests verify full behavioral parity with the
file-backed implementations including FK constraints.

No runtime rewiring — bootstrap and consumers untouched.
2026-03-25 20:39:07 +07:00
Mohamed Boudra 214f85a150 Phase 2a: Drizzle + PGlite database foundation
Add Drizzle schema for all four tables (projects, workspaces,
agent_snapshots, agent_timeline_rows), PGlite bootstrap lifecycle,
migration harness, and real contract tests proving query shapes work.

No runtime authority changes — Session, AgentManager, and existing
storage call sites are untouched. CLI cleanup removes stale projection
fields from two fetchAgentTimeline call sites.

Audit-verified: correctness (all acceptance criteria met), boundary
(no runtime files touched), complexity (minor flags resolved).
2026-03-25 20:39:07 +07:00
Mohamed Boudra acda34fc70 Phase 1d: extract in-memory timeline store 2026-03-25 20:39:07 +07:00
Mohamed Boudra ae4f9e0410 Phase 1c: isolate provider history compatibility seam 2026-03-25 20:39:07 +07:00
Mohamed Boudra b3cdfb203b Phase 1b: extract workspace reconciliation service 2026-03-25 20:39:07 +07:00
Mohamed Boudra 6eb6be77df Phase 1a: collapse snapshot mutation ownership 2026-03-25 20:39:07 +07:00
Mohamed Boudra c202bd5ff7 Phase 0: simplify timeline contract and reconnect behavior 2026-03-25 20:39:07 +07:00
Mohamed Boudra cebde228b1 Add storage revamp implementation plan 2026-03-25 20:39:07 +07:00
729 changed files with 28378 additions and 63786 deletions
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: [boudra]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+24 -24
View File
@@ -34,33 +34,15 @@ jobs:
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[2]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
@@ -125,6 +107,24 @@ jobs:
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Wait for GitHub release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 90); do
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Found release for tag $RELEASE_TAG"
exit 0
fi
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
sleep 20
done
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
exit 1
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-184
View File
@@ -1,184 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Check formatting
run: npx biome format .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck all packages
run: npm run typecheck
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
app-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
relay-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- 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'
-2
View File
@@ -4,9 +4,7 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
workflow_dispatch:
jobs:
-3
View File
@@ -9,13 +9,10 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:
deploy:
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
runs-on: ubuntu-latest
steps:
+111 -147
View File
@@ -35,43 +35,8 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Create GitHub release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
if [[ "$IS_PRERELEASE" == "true" ]]; then
prerelease_flag="--prerelease"
fi
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
fi
publish-macos:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
strategy:
fail-fast: false
matrix:
@@ -93,7 +58,24 @@ jobs:
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Setup Node
uses: actions/setup-node@v4
@@ -128,6 +110,24 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -149,113 +149,8 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
contents: write
packages: read
@@ -269,7 +164,24 @@ jobs:
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Setup Node
uses: actions/setup-node@v4
@@ -304,6 +216,24 @@ jobs:
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
@@ -321,8 +251,7 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
permissions:
contents: write
packages: read
@@ -336,7 +265,24 @@ jobs:
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Setup Node
uses: actions/setup-node@v4
@@ -379,6 +325,24 @@ jobs:
npx expo export --platform web
working-directory: packages/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
if [[ "$release_draft" == "true" ]]; then
release_type="draft"
else
release_type="release"
fi
else
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
- name: Build desktop release
shell: bash
env:
-45
View File
@@ -1,45 +0,0 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push
-59
View File
@@ -1,59 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check
+10
View File
@@ -19,6 +19,11 @@ on:
required: false
default: false
type: boolean
draft:
description: "Create missing release as draft."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
@@ -43,6 +48,7 @@ jobs:
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
INPUT_DRAFT: ${{ github.event.inputs.draft }}
shell: bash
run: |
set -euo pipefail
@@ -64,4 +70,8 @@ jobs:
args+=(--create-if-missing)
fi
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
args+=(--draft)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
-313
View File
@@ -1,318 +1,5 @@
# Changelog
## 0.1.54 - 2026-04-12
### Added
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
### Improved
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
### Fixed
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
## 0.1.53 - 2026-04-12
### Added
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
- Middle-click to close tabs on desktop.
- Keyboard shortcut to cycle themes.
### Improved
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
- Streaming markdown on mobile renders significantly faster.
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
- Relay and pairing URLs are stripped from daemon logs.
### Fixed
- Closed agent tabs no longer reappear after reconnecting.
- Desktop notification badge counts match across all workspaces.
- Host switcher status syncs correctly when switching between hosts.
## 0.1.52 - 2026-04-10
### Added
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
### Fixed
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
- Initializing agents no longer show a loading spinner as if they're running.
- Reconnecting to a running agent now works even when session persistence is unavailable.
- Error screens on desktop are now scrollable.
- Model list refreshes in the background when you open the model selector.
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
- Follow-up messages to agents that just finished a turn now work correctly.
- Commands now load properly for Pi agents.
- Internal debug output no longer appears in Claude agent timelines.
- QR scan screen cleaned up with simpler visuals.
## 0.1.50 - 2026-04-07
### Added
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
- Settings shows a Refresh button for providers and displays error details inline.
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
- Restarting an agent quickly could crash the session.
- Copilot no longer pauses for permission prompts in autopilot mode.
- Connection and pairing dialogs now display correctly on tablets.
- Orchestration errors from agents are now surfaced instead of silently lost.
- Diff stats no longer reset to zero when reconnecting.
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
- Daemon status dialog in desktop settings for quick health checks.
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
- Pinned archived agents are no longer pruned when closing tabs.
- OpenCode event stream starvation during slash command execution.
- Duplicate workspaces when multiple git worktrees share the same root.
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
- Forward-compatible provider handling so older app clients don't break on new provider types.
- Input event listener race condition in the web scrollbar hook.
- Open-project screen content now vertically centered.
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
- Shell injection, symlink escape, and pairing endpoint security hardening.
## 0.1.43 - 2026-04-02
### Added
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
- Fixed context menu for copy/paste across the desktop app.
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
- File diff tracking in workspaces now works correctly on Linux.
- iPad layout now renders correctly in desktop mode.
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
-19
View File
@@ -1,19 +0,0 @@
# CI Test Status
Tracking progress toward all-green CI.
## CI Jobs
| Job | Status | Notes |
|-----|--------|-------|
| format | unknown | `npx biome format .` |
| typecheck | unknown | `npm run typecheck` |
| server-tests | unknown | unit + integration (vitest) |
| app-tests | unknown | unit tests (vitest) |
| playwright | unknown | E2E tests (playwright) |
| relay-tests | unknown | unit tests (vitest) |
| cli-tests | unknown | local tests |
## Log
- 2026-04-10: Branch created, automated agents begin iterating
+2 -9
View File
@@ -35,8 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
npm run db:query # Show DB table row counts
npm run db:query -- "SELECT ..." # Run arbitrary SQL against PGlite
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
@@ -47,13 +47,6 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Debugging
-168
View File
@@ -1,168 +0,0 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
## How this project works
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
This means:
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
## How to contribute
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
If you want to propose a direction change, start a conversation.
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- regression fixes
- doc improvements
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
## Forks are fine
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
+10 -89
View File
@@ -4,115 +4,40 @@
<h1 align="center">Paseo</h1>
<p align="center">
<a href="https://github.com/getpaseo/paseo/stargazers">
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
</a>
<a href="https://github.com/getpaseo/paseo/releases">
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
</a>
<a href="https://x.com/moboudra">
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
</a>
<a href="https://discord.gg/jz8T2uahpH">
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
</a>
</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
---
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.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
## Getting Started
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
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, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
To run the daemon on a remote or headless machine:
```bash
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.
Then connect from the desktop app, mobile app, or CLI.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
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 ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
Quick monorepo package map:
@@ -132,12 +57,8 @@ npm run dev
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
```
+5 -17
View File
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -31,26 +31,14 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
- **Replay old messages** — Each session derives fresh encryption keys
### Trust model
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
## DNS rebinding protection
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
@@ -63,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
+1 -3
View File
@@ -46,13 +46,11 @@ adb exec-out screencap -p > screenshot.png
## Cloud build + submit (EAS)
Stable tag pushes like `v0.1.0` trigger:
Tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands
```bash
-8
View File
@@ -31,14 +31,6 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon
-428
View File
@@ -1,428 +0,0 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
---
## Directory layout
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── agents/
│ └── {project-dir}/
│ └── {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
└── push-tokens.json # Expo push notification tokens
```
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `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.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
|---|---|---|
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
|---|---|
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
|---|---|
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
console: { level, format },
file: { level, path, rotate: { maxSize, maxFiles } }
}
}
```
All fields are optional with sensible defaults.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
### 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
### Nested: ScheduleRun
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
## 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.
| 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 |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model 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
**Path:** `$PASEO_HOME/projects/projects.json`
Array of project records.
| Field | Type | Description |
|---|---|---|
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 7. 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` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 8. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"tokens": ["ExponentPushToken[...]", ...]
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---
## Client-side stores (App)
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
```typescript
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}
```
### Attachment Store (Web)
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |
+18
View File
@@ -35,6 +35,24 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the PGlite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
## Build sync gotchas
### Relay → Daemon
-109
View File
@@ -1,109 +0,0 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |
-206
View File
@@ -1,206 +0,0 @@
# Mobile Testing
## Maestro
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
Run a flow:
```bash
maestro test packages/app/maestro/my-flow.yaml
```
### Screenshots
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
```bash
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
mkdir -p /tmp/maestro-out
cd /tmp/maestro-out && maestro test "$FLOW"
```
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
### Element targeting
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
```tsx
// Component
<Pressable testID="sidebar-sessions" onPress={onPress}>
```
```yaml
# Flow
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sidebar-sessions"
```
### Conditional steps
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
```yaml
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
```
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
### Don't use launchApp against a running dev app
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
### Swipe gestures
Use `start`/`end` with percentage coordinates for precise control:
```yaml
# Edge swipe from left to open sidebar
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
```
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
### Assertions
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
For async elements, use `extendedWaitUntil`:
```yaml
- extendedWaitUntil:
visible: ".*Online.*"
timeout: 90000
```
### Dev client handling
Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
This pattern also lets agents self-verify fixes without manual user testing.
### Pattern
1. Run baseline Maestro flow (confirm feature works)
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
3. Re-run Maestro flow (confirm feature still works)
4. Repeat N iterations to catch intermittent failures
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
```bash
bash packages/app/maestro/test-sidebar-theme.sh 6 1
# Args: iterations=6, wait_seconds=1 between toggle and test
```
Key elements of the script pattern:
```bash
set -euo pipefail
ITERATIONS="${1:-3}"
for i in $(seq 1 "$ITERATIONS"); do
# Toggle system state
xcrun simctl ui booted appearance light
# Wait for change to propagate
sleep 1
# Run Maestro flow and capture result
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
echo "PASS"
else
echo "FAIL"
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
fi
done
```
## Unistyles + Reanimated
### The crash
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
### The fix
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
```tsx
// BAD: Unistyles dynamic style on Animated.View
const styles = StyleSheet.create((theme) => ({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
overflow: "hidden",
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
```
```tsx
// GOOD: static stylesheet + inline theme values
import { StyleSheet as RNStyleSheet } from "react-native";
const staticStyles = RNStyleSheet.create({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
overflow: "hidden",
},
});
const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## iOS Simulator
```bash
# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png
# Dark/light mode
xcrun simctl ui booted appearance # check current
xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
-359
View File
@@ -1,359 +0,0 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Existing ACP providers: `claude-acp`, `copilot`.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude`, `codex`, `opencode`.
---
## ACP Provider Checklist
### 1. Create the provider class
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
```ts
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const MY_PROVIDER_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
},
// Add more modes as needed
];
type MyProviderClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
}
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
}
```
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
```ts
export class CopilotACPAgentClient extends ACPAgentClient {
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
capabilities: COPILOT_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
return super.isAvailable();
}
}
```
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
```ts
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "autonomous",
label: "Autonomous",
description: "Runs without prompting",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
```
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
```ts
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
// ... existing providers ...
{
id: "my-provider",
label: "My Provider",
description: "Short description of the provider",
defaultModeId: "default",
modes: MY_PROVIDER_MODES,
// Optional: enable voice
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "some-model",
},
},
];
```
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
// ... existing factories ...
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["my-provider"],
}),
};
```
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
```tsx
import Svg, { Path } from "react-native-svg";
interface MyProviderIconProps {
size?: number;
color?: string;
}
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="..." />
</Svg>
);
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
```ts
export const agentConfigs = {
// ... existing configs ...
"my-provider": {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`:
```ts
case "my-provider":
return (
isCommandAvailable("my-agent-binary") &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array:
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"my-provider",
];
```
### 6. Run typecheck
```bash
npm run typecheck
```
This is required after every change per project rules.
---
## Direct Provider Checklist
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
### Interfaces to implement
**`AgentClient`** -- factory for sessions and model listing:
```ts
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
```
**`AgentSession`** -- a running agent conversation:
```ts
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
2. Add to the provider manifest (same as ACP step 2 above)
3. Add factory to the registry (same as ACP step 3 above)
4. Add icon (same as ACP step 4 above)
5. Add E2E config (same as ACP step 5 above)
6. Run typecheck
---
## Testing
### Manual testing with the CLI
Start the daemon if not already running, then:
```bash
# Launch an agent with your provider
paseo run --provider my-provider
# Launch with a specific model and mode
paseo run --provider my-provider --model some-model --mode default
# List running agents
paseo ls -a -g
# Check if the provider reports models
paseo models --provider my-provider
```
### E2E test patterns
The E2E configs in `agent-configs.ts` expose two helpers:
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
---
## Gotchas
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
+11 -115
View File
@@ -2,21 +2,8 @@
All workspaces share one version and release together.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure local `npm run typecheck` passes on that commit.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash
npm run release:patch
```
@@ -25,56 +12,34 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Release candidate flow
## Draft release flow
```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
npm run release:finalize # Publish npm, promote draft to published
```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
Use the RC path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
```bash
# Desktop (all platforms)
@@ -87,91 +52,22 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Changelog voice
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
## Completion checklist
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] `npm run release:patch` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
+848
View File
@@ -0,0 +1,848 @@
# Storage Revamp Plan
Status: working implementation plan
This document is meant to guide implementation and review. It captures the decisions that have already converged, names the sequencing and test gates that should not be skipped, and leaves open the product and implementation details that are still legitimately undecided.
## Goals and scope
The storage revamp moves Paseo from a mixed JSON plus in-memory model to an explicit storage layer backed by Drizzle and PGlite.
In scope:
- durable storage for project records
- durable storage for workspace records
- durable storage for agent snapshot metadata
- durable storage for committed agent timeline history
- a simplified timeline contract: committed canonical history vs provisional live updates
- removal of `epoch`/`reset`/projection-heavy timeline recovery paths
- migration and cutover from existing JSON-backed Paseo homes where source data actually exists
## Non-goals
Out of scope for this plan:
- migrating config, keypairs, server identity, or push-token files into the DB
- persisting raw provider deltas, chunk streams, or other transport-only live updates
- designing a remote/cloud database story beyond keeping the schema portable
- committing durable reasoning history in v1 unless product explicitly asks for it later
- preserving legacy timeline semantics such as `epoch`, projection metadata, or provider-history rehydrate on refresh/load
## Current state and migration hazards
Today the server persists three different kinds of state in three different ways:
- projects in `projects/projects.json`
- workspaces in `projects/workspaces.json`
- agent snapshots in `agents/{cwd}/{agent-id}.json`
Committed timeline history is different. It is still effectively an in-memory `AgentManager` concern:
- rows live in `agent.timelineRows`
- sequence numbers are assigned in memory
- retention trimming can drop older rows
- `fetchAgentTimeline` still carries `epoch`, `reset`, `gap`, projection mode, and seq-range metadata to compensate for that model
There are also two migration hazards that should be treated as foundation work, not cleanup:
1. Agent snapshot persistence is split across multiple writers.
`attachAgentStoragePersistence()` flushes `agent_state` snapshots, while `Session` also writes agent records directly for archive/unarchive and related flows. The storage revamp should collapse that into clearer ownership before the DB swap broadens the write surface.
2. Agent load and refresh still depend on provider history hydration.
`Session.ensureAgentLoaded()` resumes or recreates a provider session and then calls `hydrateTimelineFromProvider()`. That makes refresh/load behavior depend on provider-managed history, creates replay and dedup complexity, and blocks a clean storage-owned timeline contract.
The practical implication is that this is not just a database wiring task. The contract and ownership model have to be simplified first.
## Converged decisions and contracts
### Storage choice: Drizzle + PGlite
This plan assumes Drizzle plus PGlite.
Why this is the right default:
- one Postgres-flavored schema and migration pipeline for local and future hosted storage
- no native addon packaging burden in the Electron and local-daemon environment
- Drizzle keeps the schema in TypeScript and fits the existing codebase style better than a heavier codegen-oriented ORM
- PGlite is fast enough for Paseo's local CRUD and timeline query workload
This does not lock the project into every detail of local database lifecycle yet. It does lock the plan onto a Postgres-shaped schema and Drizzle-based migration workflow.
### Storage boundary
The goal is not a grand generic repository framework. The useful seams are:
- `ProjectRegistry`
- `WorkspaceRegistry`
- `AgentSnapshotStore`
- `AgentTimelineStore`
- `WorkspaceReconciliationService`
The refactor should preserve clear domain ownership:
- project and workspace records stay distinct because reconciliation and placement logic are distinct
- agent snapshot metadata stays separate from timeline history
- filesystem reconciliation stays as a service over stored records, not an accidental side effect inside session code
### Timeline contract
The room has converged on a simplified contract:
- `timeline` event with `seq` present means committed canonical history owned by Paseo storage
- `timeline` event with no `seq` means provisional live-only state and is never returned by catch-up fetches
- committed rows must already be in canonical display/history shape, not raw provider chunk shape
- `fetch-after-seq` returns committed rows only
- Phase 0 policy: reconnect does not use a subscribe-time provisional seed
- if a turn is still active after reconnect, the client resumes from future live provisional updates only
- provisional-seed ordering is therefore out of scope for Phase 0 until product explicitly chooses to add that policy
- at most one active provisional assistant stream and one active provisional reasoning stream exist per turn
- tool progress continues to correlate by existing `callId`
- reasoning is live-only for v1 unless product explicitly changes that call later
### Why `epoch` is removed
`epoch` exists today because timeline identity is not fully owned by durable storage. It resets when the in-memory window is invalidated or rebuilt, and clients carry that reset logic in public protocol state.
That is the wrong long-term contract for a DB-backed append-only history. Once committed history is storage-owned and no longer trimmed out from under clients:
- catch-up can be `after seq`
- published sequence numbers do not need to be rewritten
- clients do not need `epoch`, `reset`, or projection-derived recovery metadata
Removing `epoch` is not a cosmetic cleanup. It is the public sign that committed history is finally durable and stable.
### Why refresh/load must not rehydrate provider history
Provider history hydration made sense as a stopgap while Paseo did not own durable timeline history. It is now an obstacle.
Refresh/load must stop rehydrating provider history because it:
- makes the public history contract depend on provider-specific retention and replay behavior
- forces user-message dedup rules and other replay suppression into server logic
- creates ambiguity over whether a timeline row is provider-imported history or Paseo-owned committed history
- makes reconnect behavior depend on re-importing old provider state instead of reading committed rows from storage
The replacement contract is simpler:
- refresh/load restores agent snapshot state from Paseo storage
- committed history comes from `AgentTimelineStore`
- provisional in-flight state, if any, is subscription-scoped and may be seeded once per subscription according to the chosen policy
## Timeline consumption contract
The client model is intentionally two-layered:
- committed history is the durable tail, keyed by monotonically increasing `seq`
- provisional live state is the transient head, carried by unsequenced `timeline` events
The client keeps a committed cursor, not a provisional cursor. Provisional streaming never advances that committed cursor.
### Client mental model
The client stores:
- the last committed `seq` it has accepted for a given agent
- the committed history rows needed for the current view
- any current provisional UI state for in-progress assistant, reasoning, or tool activity
It does not store or reason about a second durable sequence space for provisional data.
### `fetch-after-seq`
`fetch-after-seq(agentId, seq)` means:
- return committed canonical history rows with `seq > cursor`
- never return provisional live state
- never return raw chunk rows
- preserve committed order
If there are no committed rows after that cursor yet, the result is empty even if an in-progress message is still streaming live.
### Subscribe-time provisional seed
A subscribe-time provisional seed, if used by the chosen policy, is a one-time delivery of the current full provisional items for that subscription.
It is not:
- a historical replay of provider deltas
- a second committed history fetch
- a second sequence space
If emitted, it must arrive before later provisional patch events for that subscription.
Phase 0 chooses the simpler policy: no subscribe-time provisional seed for now.
### Reconnect scenarios
Concrete reconnect example:
- the client has accepted committed history through `seq = 120`
- the committed tail includes rows through `120`
- the client UI also shows an in-progress assistant message in the provisional head
- that provisional assistant message has no committed seq yet
Case A: the assistant message finishes while the client is disconnected
1. The client reconnects with last committed `seq = 120`.
2. The client calls `fetch-after-seq(agentId, 120)`.
3. The server returns committed row `121`, containing the full finalized assistant message.
4. The client accepts row `121` into the durable tail and advances its committed cursor to `121`.
5. The stale provisional assistant UI from before disconnect is dropped because the committed row has now superseded it.
Case B: the assistant message has not finished by the time the client reconnects
1. The client reconnects with last committed `seq = 120`.
2. The client calls `fetch-after-seq(agentId, 120)`.
3. The server may legitimately return nothing new yet, because committed row `121` does not exist yet.
4. Live streaming resumes.
5. In Phase 0, the client continues from fresh live updates only.
6. When the assistant message finally completes, it becomes one committed row `121`.
In both cases:
- partial streaming before disconnect never advanced the committed cursor
- `fetch-after-seq` is about committed history only
- provisional streaming is transient UI state, not committed history
If the daemon restarted or the agent is unloaded:
- committed history still comes from `AgentTimelineStore`
- the client can fetch committed history by seq without the provider session being active
- provisional state is absent unless a new live subscription later seeds it
### Replacement rule without a second sequence space
The replacement rule is intentionally simple:
- committed rows are authoritative durable history
- provisional UI is best-effort and disposable
- when a committed row arrives that corresponds to previously shown provisional state, the committed row becomes the durable representation and the provisional UI is cleared
This simplicity depends on the agreed invariant that there is at most one active provisional assistant stream and one active provisional reasoning stream per turn.
This avoids introducing `epoch`, a second durable sequence space, or provider-specific replay semantics.
## Authority and ownership
The storage revamp needs explicit authority boundaries so the model cannot drift during implementation.
- `AgentTimelineStore` owns committed timeline authority
- only the committed-history authority path may assign durable `seq`
- provisional/live buffering is owned by the runtime/session path that is already handling live provider events; it is not owned by the DB
- `AgentSnapshotStore` owns durable agent snapshot persistence
- `WorkspaceReconciliationService` owns filesystem-vs-stored-workspace reconciliation
- `ProjectRegistry` and `WorkspaceRegistry` own durable structured records for those domains
- legacy structured-record import is a bootstrap path owned by the structured-record storage layer, not by ad hoc session code
Practical implications:
- providers do not assign committed seqs
- clients do not infer committed seq advancement from provisional events
- session code may read committed history, but it should not become an alternate authority for committed storage semantics
## Interfaces and module boundaries
These are design-level contracts, not implementation-level signatures.
### `AgentSnapshotStore`
Responsibilities:
- read and write durable agent snapshot metadata
- archive and unarchive snapshot records
- list and fetch stored agent snapshots without requiring an active provider session
Useful method shape:
- `get(agentId)`
- `list(filter?)`
- `upsert(snapshotRecord)`
- `archive(agentId, archivedAt)`
- `unarchive(agentId)`
### `AgentTimelineStore`
Responsibilities:
- append finalized committed history rows
- fetch committed history by tail, before-seq, or after-seq queries
- serve committed history for unloaded or restarted agents
- guarantee ordered committed seq allocation per agent
Useful method shape:
- `appendCommitted(agentId, item, metadata?) -> committedRow`
- `fetchTail(agentId, limit)`
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
- `fetchAfterSeq(agentId, seq, limit?)`
- `getLatestCommittedSeq(agentId)`
Constraints:
- no raw chunk rows in durable history
- no provisional rows in durable history
- seq assignment happens only here or in the narrow authority path directly behind it
### `ProjectRegistry`
Responsibilities:
- store and fetch durable project records
- upsert, archive, and list project records
Useful method shape:
- `get(projectId)`
- `list(filter?)`
- `upsert(projectRecord)`
- `archive(projectId, archivedAt)`
### `WorkspaceRegistry`
Responsibilities:
- store and fetch durable workspace records
- upsert, archive, and list workspace records
Useful method shape:
- `get(workspaceId)`
- `list(filter?)`
- `upsert(workspaceRecord)`
- `archive(workspaceId, archivedAt)`
### `WorkspaceReconciliationService`
Responsibilities:
- own both targeted reconcile-on-register or placement-update checks and broader stale-workspace sweeps
- compare durable workspace records against filesystem reality
- mark stale or missing workspaces according to the chosen policy
- keep reconciliation logic out of session orchestration code
Useful method shape:
- `reconcileTarget(workspaceId | cwd) -> reconcileResult`
- `reconcileSweep() -> reconciliationResult`
### Timeline-facing session/service seam
One session-facing timeline contract is useful so the app/CLI path does not need to know storage internals.
Responsibilities:
- fetch committed history for initial view, older-history pagination, or reconnect
- expose live subscription semantics
- apply the committed-vs-provisional contract consistently for app and CLI consumers
Useful method shape:
- `fetchTail(agentId, limit)`
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
- `fetchAfterSeq(agentId, seq, limit?)`
- `subscribe(agentId, options?)`
This seam is a consumer-facing contract exposed by the session layer so app and CLI callers do not need to know storage internals. It is not a second storage/service authority for committed timeline semantics.
## DB design and query shape
The plan does not need to freeze the final schema, but it should lock the query-driving shape.
### Main tables expected
- `projects`
- `workspaces`
- `agent_snapshots`
- `agent_timeline_rows`
### Fields that matter for core queries
For `agent_timeline_rows`:
- `agent_id`
- committed `seq`
- committed timestamp
- canonical item payload
- optional item kind or type discriminator only if it helps storage ergonomics without expanding the hot-path query surface
For `agent_snapshots`:
- `agent_id`
- provider
- cwd / workspace linkage
- lifecycle/status metadata
- timestamps needed for listing and resume decisions
- archive state
For `projects` and `workspaces`:
- durable IDs
- linkage fields
- display fields
- archive state
- timestamps needed for reconciliation and listing
### Main queries to support efficiently
Hot-path:
- fetch committed tail for an agent
- fetch older committed history before a known seq for scrollback / pagination
- fetch committed rows after a known seq for reconnect/catch-up
- fetch committed history for an unloaded or restarted agent through the same per-agent tail/after-seq path
- append one finalized committed row
- list/fetch agent snapshots for UI and session bootstrap
- list/fetch projects and workspaces for the app shell
Cold-path:
- legacy structured-record import
- archive/unarchive and reconciliation sweeps
### Likely indexes and why
- unique ordered `(agent_id, seq)` constraint/index on timeline rows for per-agent seq uniqueness, ordered append, and after-seq fetch
- index on agent snapshot status/update time for list views
- index on workspace `project_id` for grouped workspace listing
- index on archive state where it materially reduces active-record scans
This is enough to guide implementation without prematurely optimizing secondary filters or analytics-style queries. Per-type filtering is not part of the required v1 hot path.
### Not optimizing yet
The v1 design is intentionally not optimizing for:
- cross-agent global timeline queries
- full-text search over timeline history
- reasoning-history analytics
- provider-specific chunk replay
- multi-tenant or remote-hosted query patterns
## Timeline query scenarios and invariants
Primary timeline queries:
### Fetch tail for initial view
- input: `agentId`, `limit`
- result: latest committed canonical rows only
- invariant: tail fetch does not depend on an active provider session
### Fetch after seq for reconnect/catch-up
- input: `agentId`, last committed `seq`
- result: committed canonical rows with greater seq
- invariant: no provisional rows are returned
- invariant: no gaps or duplicates appear when combined with resumed live streaming
### Fetch before seq for older-history pagination
- input: `agentId`, `beforeSeq`, `limit`
- result: older committed canonical rows with `seq < beforeSeq`
- invariant: older-history pagination uses the same committed-row contract as tail and after-seq reads
- invariant: no provisional rows or raw chunk rows are returned
### Fetch committed history for unloaded or restarted agent
- input: `agentId`
- result: committed history from storage
- invariant: provider-history rehydrate is not required
### Append finalized committed row
- input: finalized logical history item
- result: one committed row with one durable seq
- invariant: no raw chunk rows are written as durable history
Core invariants:
- provisional streaming does not advance the committed seq cursor
- durable history contains finalized canonical rows only
- clients recover from disconnect by fetching committed rows after the last committed seq
- if nothing finalized while disconnected, `fetch-after-seq` may return nothing and live streaming resumes
- if something finalized while disconnected, the returned committed row supersedes stale provisional client UI
- no gaps or duplicates are allowed across catch-up plus live resume
## v1 design non-goals
The design deliberately does not support these in v1:
- durable reasoning history
- historical backfill of timeline data that never existed in Paseo storage
- provider-history rehydrate as a normal refresh/load mechanism
- raw chunk durability
- multiple simultaneous provisional assistant or reasoning streams beyond the agreed single-stream invariant
## Phase plan
Each phase below includes the required goals, sequencing, tests, exit gate, and suggested commit boundary. The phases are intentionally practical rather than over-detailed.
Suggested commit boundaries are approximate. They are there to keep reviewable slices and clear gates, not to force an artificial commit count if two adjacent steps clearly belong together.
### Phase 0: Contract and model cleanup before DB wiring
Goal:
Lock the public and internal timeline contract before introducing a new storage backend.
What changes:
- remove `epoch` from shared timeline cursor/message shapes
- define committed-vs-provisional semantics around `timeline + optional seq`
- remove projection-specific public response baggage such as `reset`, `projection`, seq-range collapse metadata, and related epoch gates
- update server/session/app catch-up logic to use seq-backed committed history plus provisional head seeding
- make reasoning explicitly live-only for v1
- document and enforce the single-provisional-stream invariant per kind
Dependencies:
- none beyond the contract decisions already converged in the room
Tests required:
- shared message parsing tests for the simplified timeline payloads
- app reducer tests for committed append, provisional merge, and reconnect without provisional seeding
- session/WebSocket tests for `live -> disconnect -> fetch-after-seq -> subscribe -> resume`
- explicit no-provisional-seed reconnect test proving that reconnect resumes from committed catch-up plus future live events only
Exit gate:
The client and server can exchange committed and provisional timeline updates without `epoch`, `reset`, or projection metadata, and all timeline catch-up tests pass with the new contract.
Can happen in parallel:
- shared message/schema cleanup
- app reducer and bootstrap policy cleanup
- server session fetch/subscribe cleanup
Must stay sequential:
- final protocol shape must settle before DB schema and timeline store work begin
Suggested commit boundaries:
- protocol and message schema cleanup
- app/session reducer and catch-up logic cleanup
- tests locking the new contract
### Phase 1: Storage boundary refactor with current backends
Goal:
Separate domain ownership and persistence seams while still using the current JSON and in-memory backends.
What changes:
- introduce explicit `AgentSnapshotStore` and `AgentTimelineStore` seams
- keep `ProjectRegistry` and `WorkspaceRegistry` as concrete domain seams rather than wrapping them in a generic repository abstraction
- extract workspace reconciliation into a dedicated `WorkspaceReconciliationService`
- collapse duplicate snapshot-writer paths so snapshot persistence ownership is explicit
- isolate current in-memory timeline logic behind the timeline store interface
- isolate refresh/load dependence on provider history rehydrate behind explicit seams so Phase 4 can remove it cleanly without another structural refactor
Dependencies:
- Phase 0 contract and message-shape cleanup should be settled enough that the extracted seams do not preserve `epoch`-era behavior by accident
Tests required:
- store contract tests for snapshot store and timeline store
- registry contract tests for projects and workspaces
- reconciliation tests using real temp directories and real filesystem mutations
- refresh/load tests proving any remaining provider-history dependency is isolated behind explicit storage/session seams rather than scattered through the runtime
Exit gate:
The server can run unchanged behavior through explicit storage interfaces, duplicate snapshot-writer ownership is collapsed, and any remaining provider-history rehydrate dependency is isolated to a narrow compatibility path that Phase 4 can delete.
Can happen in parallel:
- snapshot-store extraction
- reconciliation extraction
- timeline-store interface extraction
Must stay sequential:
- duplicate snapshot-writer cleanup should land before DB-backed snapshot cutover
- timeline store contract should settle before DB timeline work begins
Suggested commit boundaries:
- snapshot-store ownership cleanup
- reconciliation-service extraction
- timeline-store extraction with in-memory adapter
### Phase 2: DB foundation
Goal:
Land the database runtime, schema, and migration tooling without yet making the DB the source of truth for every domain.
What changes:
- add Drizzle schema and migration workflow
- add PGlite database bootstrap and lifecycle management
- define tables for projects, workspaces, agent snapshots, and committed timeline history
- choose indexes based on the first real query set, especially `agent_id + seq`
- implement DB-backed adapters for the storage seams introduced in Phase 1
Dependencies:
- Phase 1 storage seams should exist before DB adapters become the main implementation path
Tests required:
- migration tests on fresh databases
- migration tests on incremental schema upgrades
- DB adapter tests against a real PGlite database
- startup/shutdown tests for DB bootstrap in the daemon environment
Exit gate:
The repo can create, migrate, and query a real PGlite-backed Drizzle database in tests, and the new adapters satisfy the same contract tests as the current backends.
Suggested commit boundaries:
- Drizzle and PGlite foundation
- schema plus migrations
- DB adapters wired behind existing interfaces
### Phase 3: Structured-record cutover and legacy JSON import
Goal:
Move projects, workspaces, and agent snapshot metadata onto the DB, with explicit import rules from existing Paseo homes.
What changes:
- make the DB-backed project/workspace stores authoritative
- make the DB-backed agent snapshot store authoritative
- import legacy `projects.json`, `workspaces.json`, and agent snapshot JSON into the DB
- run workspace reconciliation against stored records and filesystem state
- keep archive/unarchive and related metadata flows working through the new store ownership model
Dependencies:
- Phase 2 DB bootstrap, migrations, and DB-backed structured-record adapters must be stable first
Tests required:
- import tests from existing JSON files into empty DBs
- idempotent re-import tests where the DB already contains matching rows
- reconciliation tests covering deleted directories and archived-agent edge cases
- agent list/fetch/workspace list session tests against DB-backed stores
Exit gate:
A daemon can start from a legacy Paseo home, import structured records into the DB, restart from the same home again without duplication or state drift, and serve project/workspace/agent metadata without depending on JSON files as the source of truth.
Ordered rollout note:
- first, project/workspace authority plus reconciliation and import harness
- second, restart/idempotence verification on the structured-record path
- third, agent snapshot metadata authority
- structured records should cut over before timeline storage so the timeline phase can rely on stable DB-backed agent identity and metadata
Suggested commit boundaries:
- project/workspace DB cutover plus import harness and reconciliation
- agent snapshot DB cutover and restart/idempotence tests
### Phase 4: Timeline storage and catch-up cutover
Goal:
Move committed timeline history to storage-owned append-only rows and remove provider-history rehydrate from refresh/load.
What changes:
- make `AgentTimelineStore` authoritative for committed history
- write one committed row per finalized history item
- stop storing raw chunk rows as committed history
- keep provisional live updates in memory/subscription scope only
- make fetch/catch-up query committed rows by `agent_id + seq`
- remove provider-history rehydrate from normal refresh/load flows
- remove retention-window timeline trimming and epoch-reset behavior for committed history
Dependencies:
- Phase 0 contract cleanup
- Phase 2 DB foundation
- Phase 3 structured-record cutover, so timeline rows attach to stable DB-backed agent metadata and identity
Tests required:
- timeline store contract tests against real PGlite
- live-to-commit tests for assistant, tool, and reasoning-live-only behavior
- reconnect tests covering committed catch-up plus provisional seed ordering
- migration/cutover tests proving refresh/load do not import provider history
- scenario tests for:
- live stream while connected
- disconnect after committed seq `N`, reconnect, `fetch-after-seq(N)`, then resume live without gaps or duplicates
- daemon restart, then fetch committed history without provider rehydrate
- `attach`/`logs` against an agent with committed stored history but no active provider session
Exit gate:
Committed timeline history survives daemon restart, reconnect from a known committed seq yields ordered history with no gaps or duplicates across catch-up plus live resume, including the provisional-seed case, and refresh/load no longer depend on provider history replay.
Internal sub-gates:
- write-path parity is established before the DB-backed timeline writer becomes authoritative
- `fetch-after-seq` parity is established before legacy committed-history reads are removed
- reconnect no-gap/no-duplicate behavior is proven before timeline cutover is considered complete
- refresh/load de-rehydrate removal lands only after committed-history write and read authority are stable
Can happen in parallel:
- timeline write path
- fetch/catch-up query path
- CLI/app verification against storage-backed committed history behavior
Must stay sequential:
- this phase depends on Phase 0 contract cleanup and Phase 2 DB foundation
- it should land after Phase 3 so agent and workspace identity are already DB-backed
Suggested commit boundaries:
- gated committed timeline store plus write/read parity work, not yet authoritative until matching read-path coverage exists
- authoritative fetch/catch-up cutover together with reconnect scenario coverage
- refresh/load de-rehydrate cleanup and related tests
### Phase 5: Cleanup and removal
Goal:
Delete legacy paths once DB-backed storage and the simplified contract are proven.
What changes:
- remove legacy JSON source-of-truth paths for projects, workspaces, and agent snapshots
- remove in-memory committed timeline ownership and retention trimming
- remove leftover compatibility code and dead reducers from the pre-Phase-0 timeline model
- remove provider-history hydration paths that are no longer part of refresh/load
- update architecture and developer docs to reflect the new ownership model
Dependencies:
- Phase 3 and Phase 4 must both have passed their exit gates
Tests required:
- full server typecheck and existing relevant test suites
- targeted regression tests for deleted legacy branches
- migration tests proving old homes still import correctly into the new model
Exit gate:
There is one durable storage path for structured records and committed timeline history, normal runtime no longer writes legacy JSON or in-memory committed-history branches, and no runtime behavior depends on the removed JSON/in-memory history model.
Suggested commit boundaries:
- legacy path removal
- doc updates and final contract cleanup
## Legacy data and import constraints
The migration story is intentionally narrower than a full historical backfill.
What can be imported from existing Paseo homes:
- projects from `projects/projects.json`
- workspaces from `projects/workspaces.json`
- agent snapshot metadata from agent JSON files
What cannot be broadly backfilled:
- committed timeline history that never existed in Paseo storage
- raw provider history that would need to be rehydrated on refresh/load
- provisional live state from prior daemon lifetimes
That means the honest timeline migration story is:
- existing committed history only exists for whatever Paseo has already written under the new timeline store
- after cutover, timeline history is durable and append-only
- before cutover, old homes do not gain full historical timelines unless a separate import source is defined later
This is a feature, not a gap. It keeps the new contract storage-owned instead of reintroducing provider-specific replay semantics.
## Orchestrator audit plan
Every implementation slice requires an independent audit pass before it is accepted. Audit agents verify; they do not edit files. A phase is not complete until its audit gate passes.
Audit prompts should be checklist-based and evidence-driven. The point of these passes is to catch regressions, hidden behavior changes, missing tests, two-authority drift, and unnecessary complexity before the next phase begins.
### Phase 0 audit
The audit pass must verify:
- the public contract actually removed `epoch`, `reset`, and projection-heavy response baggage where intended
- `timeline` with `seq` vs without `seq` behaves as specified
- the explicit `seq = 120 -> 121` reconnect scenario is covered by tests
- older-history pagination still works under the committed-history contract
- app reducer and session/WebSocket behavior match the agreed contract
### Phase 1 audit
The audit pass must verify:
- no functional behavior changed beyond the intended seam refactor
- duplicate snapshot-writer ownership is actually collapsed
- reconciliation ownership is actually extracted from `Session`
- provider-history dependency is isolated and not still scattered through runtime code
- seam contract tests exist and pass
### Phase 2 audit
The audit pass must verify:
- fresh migrations succeed
- incremental migrations succeed
- DB adapters pass the same contract tests as current backends
- bootstrap and shutdown are stable
- no DB path is prematurely authoritative unless explicitly intended
### Phase 3 audit
The audit pass must verify:
- import from a legacy Paseo home works
- restarting from the same legacy home does not duplicate or drift structured records
- project, workspace, and agent metadata behavior matches prior behavior
- reconciliation still surfaces stale or missing workspaces correctly
- archive, unarchive, list, and fetch flows still work
### Phase 4 audit
The audit pass must verify:
- committed history survives daemon restart
- `fetch-after-seq` and `fetch-before-seq` behave correctly
- no raw chunk rows are persisted
- reconnect satisfies the no-gap/no-duplicate invariant
- the `120 -> 121` reconnect scenario is explicitly verified
- unloaded or restarted agent history reads work without provider rehydrate
- CLI and app history behaviors such as `logs`, `attach`, and timeline fetch still work against committed stored history
### Phase 5 audit
The audit pass must verify:
- normal runtime no longer writes legacy JSON or in-memory committed-history branches
- dead compatibility branches are actually gone
- relevant test suites and typecheck pass
- legacy-home import still works under the final model
## Open questions and risks
Open product or implementation questions:
- should subscribe-time provisional seeding always happen when a turn is active, or only when the client asks for streaming state
- do we want a durable system/error timeline row for failed or canceled turns, or should those remain transport-only
- do any providers require an extra adapter constraint to uphold the single-provisional-stream invariant
Main risks and mitigations:
- risk: the plan quietly recreates projection complexity inside the DB schema
mitigation: store canonical committed rows only, and keep raw/provisional data out of durable history
- risk: refresh/load still leaks provider-history import back into the committed-history path
mitigation: make de-rehydrate behavior a named Phase 4 gate with dedicated tests
- risk: legacy import over-promises timeline backfill
mitigation: keep timeline migration constraints explicit and separate from structured-record import
- risk: the storage refactor turns into a large abstraction exercise
mitigation: keep the seam list concrete and domain-shaped; avoid generic repository frameworks
- risk: sequencing gets inverted and DB work begins before the protocol and ownership model settle
mitigation: keep Phase 0 and Phase 1 as hard prerequisites for DB timeline cutover
+170
View File
@@ -0,0 +1,170 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds
-68
View File
@@ -1,68 +0,0 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-59
View File
@@ -1,59 +0,0 @@
{
description = "Paseo - self-hosted daemon for AI coding agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
in
{
default = paseo;
paseo = paseo;
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
{
imports = [ ./nix/module.nix ];
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.python3
];
};
}
);
};
}
-172
View File
@@ -1,172 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.paseo;
in
{
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
package = lib.mkPackageOption pkgs "paseo" { };
user = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "User account under which Paseo runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "Group under which Paseo runs.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default =
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/${cfg.user}/.paseo";
defaultText = lib.literalExpression ''
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/''${cfg.user}/.paseo"
'';
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "Port for the Paseo daemon to listen on.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the Paseo daemon to bind to.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
Set to `true` to allow any host (not recommended).
'';
};
relay = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
inheritUserEnvironment = lib.mkOption {
type = lib.types.bool;
default = cfg.user != "paseo";
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
description = ''
Whether to include the user's profile PATH in the service environment.
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
}
'';
description = "Extra environment variables for the Paseo daemon.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.paseo = {
description = "Paseo - self-hosted daemon for AI coding agents";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
"${cfg.package}/bin/paseo-server"
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
Restart = "on-failure";
RestartSec = 5;
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
};
};
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
-144
View File
@@ -1,144 +0,0 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
}:
buildNpmPackage rec {
pname = "paseo";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store";
};
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-H+hYTcnYBby1aL7kaxA9Y8fX/aW4iAPc5zeMDV8RCGk=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
# We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
];
# Don't use the default npm build hook — we need a custom build sequence
dontNpmBuild = true;
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/paseo
# Copy root package metadata
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
--set NODE_PATH "$out/lib/paseo/node_modules"
runHook postInstall
'';
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+1223 -301
View File
File diff suppressed because it is too large Load Diff
+15 -27
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.54",
"version": "0.1.33",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -37,30 +37,26 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -73,7 +69,6 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -82,14 +77,7 @@
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
@@ -4,7 +4,6 @@ on:
push:
tags:
- "v*"
- "!v*-rc.*"
workflow_dispatch: {}
jobs:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

-108
View File
@@ -1,108 +0,0 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
connectArchiveTabDaemonClient,
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 120_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
});
+6 -9
View File
@@ -448,15 +448,12 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
+12 -16
View File
@@ -2,7 +2,6 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -84,36 +83,33 @@ export function createReplyTurn(label: string): {
};
}
type DaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: DaemonClientConfig,
) => DaemonClientInstance
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
@@ -131,7 +127,7 @@ export async function seedBottomAnchorAgent(input: {
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.4-mini",
model: "gpt-5.1-codex-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
-266
View File
@@ -1,266 +0,0 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
};
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
type ArchiveTabDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: ArchiveTabDaemonClientConfig,
) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "default",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
);
}
return {
id: created.id,
title: input.title,
cwd: input.cwd,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
await page.goto("/");
}
export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.goto("/");
}
export async function openWorkspaceWithAgents(
page: Page,
agents: [ArchiveTabAgent, ArchiveTabAgent],
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function expectWorkspaceArchiveOutcome(
page: Page,
input: { archivedAgentId: string; survivingAgentId: string },
): Promise<void> {
await expectWorkspaceTabHidden(page, input.archivedAgentId);
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
await waitForWorkspaceTabsVisible(page);
}
export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
}
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
): Promise<void> {
const row = getSessionRowByTitle(page, input.title);
await expect(row).toBeVisible({ timeout: 30_000 });
const box = await row.boundingBox();
if (!box) {
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
await expectSessionRowArchived(page, input.title);
}
+1 -1
View File
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
@@ -1,27 +0,0 @@
import WebSocket from "ws";
type WebSocketLike = {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
export type NodeWebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> },
) => WebSocketLike;
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
return (url: string, options?: { headers?: Record<string, string> }) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
}
-237
View File
@@ -1,237 +0,0 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
type TerminalPerfDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: TerminalPerfDaemonClientConfig,
) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
function buildWorkspaceUrl(cwd: string): string {
return buildHostWorkspaceRoute(getServerId(), cwd);
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
await page.waitForURL(
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
{ timeout: 15_000 },
);
// Wait for daemon connection (sidebar shows host label)
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 15_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.scrollIntoViewIfNeeded();
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -1,165 +0,0 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(
page,
(text) => text.includes(sentinel),
THROUGHPUT_BUDGET_MS + 15_000,
);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(
elapsedMs,
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
).toBeLessThan(THROUGHPUT_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
-2
View File
@@ -1,2 +0,0 @@
# Maestro takeScreenshot artifacts
*.png
@@ -1,31 +0,0 @@
appId: sh.paseo
---
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
# Small pause for close animation
- takeScreenshot: 00-sidebar-closed
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
# Verify sidebar opened
- assertVisible:
id: "sidebar-sessions"
- takeScreenshot: 01-sidebar-opened
# Close sidebar via swipe left
- swipe:
direction: LEFT
duration: 300
- takeScreenshot: 02-sidebar-closed-again
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Verification loop for sidebar theme bug.
#
# Maestro can't toggle iOS appearance, so this script bridges the gap:
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
# still works. Runs N iterations to catch intermittent failures.
#
# Usage:
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
ITERATIONS="${1:-3}"
WAIT_SECS="${2:-1}"
mkdir -p "$OUT_DIR"
echo "=== Sidebar Theme Bug Verification ==="
echo "Output dir: $OUT_DIR"
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
FAILURES=0
for i in $(seq 1 "$ITERATIONS"); do
echo ""
echo "========== Iteration $i / $ITERATIONS =========="
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
echo "Current appearance: $CURRENT"
if [ "$CURRENT" = "dark" ]; then
xcrun simctl ui booted appearance light
echo "Switched to light mode"
else
xcrun simctl ui booted appearance dark
echo "Switched to dark mode"
fi
echo "Waiting ${WAIT_SECS}s..."
sleep "$WAIT_SECS"
ITER_DIR="$OUT_DIR/iter-$i"
mkdir -p "$ITER_DIR"
# Run maestro from the output dir so takeScreenshot artifacts land there
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
echo " -> PASS (iteration $i)"
else
echo " -> FAIL (iteration $i) — bug reproduced!"
FAILURES=$((FAILURES + 1))
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
fi
done
# Restore to dark mode
xcrun simctl ui booted appearance dark
echo ""
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
echo "Output: $OUT_DIR"
+5 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.54",
"version": "0.1.33",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.54",
"@getpaseo/highlight": "0.1.54",
"@getpaseo/server": "0.1.54",
"@getpaseo/expo-two-way-audio": "0.1.33",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/server": "0.1.33",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -108,15 +108,12 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.59.1",
"ws": "^8.20.0"
"wrangler": "^4.59.1"
}
}
-6
View File
@@ -19,12 +19,6 @@
body {
overflow: hidden;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
}
}
/* These styles make the root element full-height */
#root {
display: flex;
@@ -0,0 +1,36 @@
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false;
}
if (input.hasTimedOut) {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false;
}
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
+115 -325
View File
@@ -14,10 +14,10 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
@@ -25,15 +25,10 @@ import {
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -58,7 +53,7 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
@@ -69,9 +64,7 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -83,18 +76,7 @@ import {
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
@@ -105,7 +87,7 @@ function PushNotificationRouter() {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
if (getIsDesktop()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
@@ -222,126 +204,41 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
})();
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
if (!cancelled) {
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setPhase("online");
setError(null);
setReady(true);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
});
return () => {
cancelled = true;
cancelAnyOnline?.();
};
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={state}>
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
@@ -358,8 +255,6 @@ interface AppContainerProps {
chromeEnabled?: boolean;
}
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
function AppContainer({
children,
selectedAgentId,
@@ -367,62 +262,23 @@ function AppContainer({
}: AppContainerProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile: isCompactLayout,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
});
const containerStyle = useMemo(
@@ -433,12 +289,10 @@ function AppContainer({
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>{children}</View>
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
@@ -447,7 +301,7 @@ function AppContainer({
</View>
);
if (!isCompactLayout) {
if (!isMobile) {
return content;
}
@@ -464,28 +318,14 @@ function MobileGestureWrapper({
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useSidebarAnimation();
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
openAgentList();
}, [openAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
@@ -528,7 +368,7 @@ function MobileGestureWrapper({
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
runOnJS(openAgentList)();
} else {
animateToClose();
}
@@ -543,9 +383,8 @@ function MobileGestureWrapper({
backdropOpacity,
animateToOpen,
animateToClose,
handleGestureOpen,
openAgentList,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
],
@@ -560,34 +399,24 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (isLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
UnistylesRuntime.setTheme(settings.theme);
}
}, [settingsLoading, settings.theme]);
}, [isLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
return;
}
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
if (isLoading) {
return <LoadingView />;
}
return (
<VoiceProvider>
@@ -616,7 +445,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
})
.catch((error) => {
if (cancelled) return;
@@ -641,84 +470,6 @@ function OfferLinkListener({
return null;
}
interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
}
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
}
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
})
.then((dispose) => {
if (disposed) {
dispose();
return;
}
unlisten = dispose;
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -734,7 +485,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname
@@ -768,36 +519,6 @@ function FaviconStatusSync() {
return null;
}
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
</Stack>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
@@ -818,11 +539,60 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
export default function RootLayout() {
const { theme } = useUnistyles();
function LoadingView({ message }: { message?: string } = {}) {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
@@ -835,9 +605,29 @@ export default function RootLayout() {
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
@@ -1,20 +1,11 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentReadyRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentReadyRouteContent() {
const router = useRouter();
const params = useLocalSearchParams<{
serverId?: string;
@@ -67,7 +58,7 @@ function HostAgentReadyRouteContent() {
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
@@ -98,14 +89,14 @@ function HostAgentReadyRouteContent() {
);
return;
}
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
});
return () => {
+7 -11
View File
@@ -1,6 +1,5 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
@@ -13,14 +12,6 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
export default function HostIndexRoute() {
return (
<HostRouteBootstrapBoundary>
<HostIndexRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostIndexRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
@@ -57,6 +48,11 @@ function HostIndexRouteContent() {
);
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
return rightTime - leftTime;
});
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
@@ -72,11 +68,11 @@ function HostIndexRouteContent() {
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
return;
}
router.replace(buildHostOpenProjectRoute(serverId));
router.replace(buildHostOpenProjectRoute(serverId) as any);
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
@@ -1,16 +1,7 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function HostOpenProjectRoute() {
return (
<HostRouteBootstrapBoundary>
<HostOpenProjectRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
@@ -1,16 +1,7 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function HostAgentsRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentsRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
@@ -1,10 +1,3 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function HostSettingsRoute() {
return (
<HostRouteBootstrapBoundary>
<SettingsScreen />
</HostRouteBootstrapBoundary>
);
}
export default SettingsScreen;
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { Platform } from "react-native";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useEffect, useRef } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
@@ -36,17 +35,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
}
export default function HostWorkspaceLayout() {
return (
<HostRouteBootstrapBoundary>
<HostWorkspaceLayoutContent />
</HostRouteBootstrapBoundary>
);
}
function HostWorkspaceLayoutContent() {
const rootNavigationState = useRootNavigationState();
const router = useRouter();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
@@ -65,9 +55,6 @@ function HostWorkspaceLayoutContent() {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
@@ -76,30 +63,19 @@ function HostWorkspaceLayoutContent() {
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
}
const route = openIntent
? prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
})
: buildHostWorkspaceRoute(serverId, workspaceId);
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
if (Platform.OS === "web" && typeof window !== "undefined") {
const url = new URL(window.location.href);
if (url.searchParams.has("open")) {
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
}
router.replace(route as any);
}, [openValue, router, serverId, workspaceId]);
setIntentConsumed(true);
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
if (openValue && !intentConsumed) {
if (openValue) {
return null;
}
+75 -18
View File
@@ -1,15 +1,20 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from "@/app-support/index-startup";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
@@ -28,29 +33,81 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
}
return firstOnlineServerId;
},
() => null,
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!storeReady) {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
return null;
}
+54 -8
View File
@@ -6,6 +6,8 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
@@ -59,7 +61,7 @@ const styles = StyleSheet.create((theme) => ({
position: "absolute",
width: 36,
height: 36,
borderColor: theme.colors.accent,
borderColor: theme.colors.palette.blue[400],
},
cornerTL: {
left: 0,
@@ -146,16 +148,33 @@ export default function PairScanScreen() {
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
const returnToSource = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
router.replace(buildHostRootRoute(serverId) as any);
return;
}
if (source === "editHost" && targetServerId) {
@@ -171,7 +190,7 @@ export default function PairScanScreen() {
router.back();
} catch {
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId));
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, sourceServerId, targetServerId],
@@ -190,7 +209,7 @@ export default function PairScanScreen() {
router.back();
} catch {
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId));
router.replace(buildHostSettingsRoute(sourceServerId) as any);
return;
}
router.replace("/" as any);
@@ -205,6 +224,7 @@ export default function PairScanScreen() {
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
if (pendingNameHost) return;
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
@@ -228,7 +248,7 @@ export default function PairScanScreen() {
return;
}
const { client, hostname } = await connectToDaemon(
const { client } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -239,7 +259,13 @@ export default function PairScanScreen() {
);
await client.close().catch(() => undefined);
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
if (isNewHost) {
setPendingNameHost({ serverId: profile.serverId, hostname: null });
return;
}
returnToSource(profile.serverId);
} catch (error) {
@@ -250,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -281,6 +307,25 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
{pendingNameHost ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingNameHost.serverId;
setPendingNameHost(null);
returnToSource(serverId);
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});
}}
/>
) : null}
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
@@ -314,6 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing…
+1 -1
View File
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId));
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [router, targetServerId]);
if (!targetServerId) {
@@ -1,5 +1,4 @@
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
import { isAbsolutePath } from "@/utils/path";
export function createNativeFileAttachmentStore() {
return createLocalFileAttachmentStore({
@@ -9,17 +8,8 @@ export function createNativeFileAttachmentStore() {
if (attachment.storageKey.startsWith("file://")) {
return attachment.storageKey;
}
if (isAbsolutePath(attachment.storageKey)) {
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
// UNC paths: \\server\share -> file://server/share
if (attachment.storageKey.startsWith("\\\\")) {
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
}
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
if (attachment.storageKey.startsWith("/")) {
return `file://${attachment.storageKey}`;
}
return attachment.storageKey;
},
+2 -2
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isElectronRuntime()) {
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { pathToFileUri } from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
});
it("converts Windows drive-letter paths to file URIs", () => {
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
});
it("converts UNC paths to host-based file URIs", () => {
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
});
it("passes through file URIs unchanged", () => {
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
});
it("passes through relative paths unchanged", () => {
expect(pathToFileUri("relative/path")).toBe("relative/path");
});
});
+1 -13
View File
@@ -1,5 +1,4 @@
import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
@@ -54,21 +53,10 @@ export function pathToFileUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
if (!isAbsolutePath(path)) {
return path;
}
if (path.startsWith("/")) {
return `file://${path}`;
}
// UNC paths: \\server\share -> file://server/share
if (path.startsWith("\\\\")) {
return `file:${path.replace(/\\/g, "/")}`;
}
return `file:///${path.replace(/\\/g, "/")}`;
return path;
}
export function fileUriToPath(uri: string): string {
@@ -3,8 +3,7 @@ import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetModal,
@@ -29,8 +28,6 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: 520,
maxHeight: "85%",
flexShrink: 1,
minHeight: 0,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
@@ -57,13 +54,11 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
desktopScroll: {
flexShrink: 1,
minHeight: 0,
flex: 1,
},
desktopContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
flexGrow: 1,
},
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,
@@ -120,7 +115,7 @@ export function AdaptiveModalSheet({
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -240,7 +235,7 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
@@ -152,7 +151,7 @@ export function AddHostModal({
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null);
@@ -219,7 +218,6 @@ export function AddHostModal({
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
@@ -773,8 +773,7 @@ export function ModelDropdown({
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedLabel =
models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
const helperText = error
? undefined
@@ -1445,7 +1444,11 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
...theme.shadow.md,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: "hidden",
},
@@ -1,7 +1,6 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated from "react-native-reanimated";
@@ -13,7 +12,6 @@ import {
DraftAgentStatusBar,
type DraftAgentStatusBarProps,
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -49,7 +47,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
type QueuedMessage = {
id: string;
@@ -77,8 +74,6 @@ interface AgentInputAreaProps {
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
@@ -108,7 +103,6 @@ export function AgentInputArea({
clearDraft,
autoFocus = false,
onAddImages,
onFocusInput,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
@@ -133,15 +127,11 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
};
}),
);
@@ -155,8 +145,10 @@ export function AgentInputArea({
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
const isDesktopWebBreakpoint =
Platform.OS === "web" &&
UnistylesRuntime.breakpoint !== "xs" &&
UnistylesRuntime.breakpoint !== "sm";
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER;
@@ -216,21 +208,6 @@ export function AgentInputArea({
onAddImages?.(addImages);
}, [addImages, onAddImages]);
const focusInput = useCallback(() => {
if (Platform.OS !== "web") return;
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null;
return el != null && document.activeElement === el;
},
});
}, []);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => {
onMessageSent?.();
@@ -425,10 +402,6 @@ export function AgentInputArea({
}
switch (action.id) {
case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.focus":
if (Platform.OS !== "web") {
messageInputRef.current?.focus();
@@ -467,10 +440,8 @@ export function AgentInputArea({
handlerId: keyboardHandlerIdRef.current,
actions: [
"message-input.focus",
"message-input.send",
"message-input.dictation-toggle",
"message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
@@ -596,14 +567,14 @@ export function AgentInputArea({
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null;
const rightContent = (
@@ -641,28 +612,11 @@ export function AgentInputArea({
</View>
);
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
typeof agentState.contextWindowUsedTokens === "number";
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
const beforeVoiceContent = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
<AgentStatusBar agentId={agentId} serverId={serverId} />
);
return (
@@ -737,12 +691,10 @@ export function AgentInputArea({
disabled={isSubmitLoading}
isInputActive={isInputActive}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}
@@ -814,12 +766,6 @@ const styles = StyleSheet.create(((theme: Theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
+3 -4
View File
@@ -10,8 +10,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -215,7 +214,7 @@ export function AgentList({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -241,7 +240,7 @@ export function AgentList({
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
router.navigate(route);
router.navigate(route as any);
},
[isActionSheetVisible, onAgentSelect],
);
@@ -1,31 +0,0 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {
const queryClient = new QueryClient();
const observer = new QueryObserver(queryClient, {
queryKey: ["providerModels", "server-1", "__missing_provider__"],
enabled: false,
queryFn: async () => [],
});
const result = observer.getCurrentResult();
expect(result.isPending).toBe(true);
expect(result.isLoading).toBe(false);
expect(result.isFetching).toBe(false);
expect(isProviderModelsQueryLoading(result)).toBe(false);
});
it("treats an active fetch as loading", () => {
expect(
isProviderModelsQueryLoading({
isLoading: false,
isFetching: true,
}),
).toBe(true);
});
});
@@ -1,8 +0,0 @@
interface ProviderModelsQueryState {
isFetching: boolean;
isLoading: boolean;
}
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
return input.isLoading || input.isFetching;
}
@@ -1,7 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
@@ -15,31 +13,6 @@ describe("getStatusSelectorHint", () => {
});
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
});
describe("normalizeModelId", () => {
it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull();
+164 -522
View File
@@ -1,28 +1,13 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
Brain,
ChevronDown,
ListTodo,
Settings2,
ShieldAlert,
ShieldCheck,
ShieldOff,
Zap,
} from "lucide-react-native";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
toggleFavoriteModel,
useFormPreferences,
} from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,21 +18,17 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from "@server/server/agent/provider-manifest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
@@ -57,11 +38,7 @@ type StatusOption = {
label: string;
};
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type StatusSelector = "provider" | "mode" | "model" | "thinking";
type ControlledAgentStatusBarProps = {
provider: string;
@@ -74,21 +51,11 @@ type ControlledAgentStatusBarProps = {
modelOptions?: StatusOption[];
selectedModelId?: string;
onSelectModel?: (modelId: string) => void;
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
};
export interface DraftAgentStatusBarProps {
@@ -108,17 +75,12 @@ export interface DraftAgentStatusBarProps {
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
disabled?: boolean;
}
interface AgentStatusBarProps {
agentId: string;
serverId: string;
onDropdownClose?: () => void;
}
function findOptionLabel(
@@ -133,38 +95,6 @@ function findOptionLabel(
return selected?.label ?? fallback;
}
const FEATURE_ICONS: Record<string, typeof Zap> = {
"list-todo": ListTodo,
zap: Zap,
};
function getFeatureIcon(icon?: string) {
return (icon && FEATURE_ICONS[icon]) || Settings2;
}
function getFeatureIconColor(
featureId: string,
enabled: boolean,
palette: {
blue: { 400: string };
yellow: { 400: string };
},
foregroundMuted: string,
): string {
if (!enabled) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(featureId)) {
case "blue":
return palette.blue[400];
case "yellow":
return palette.yellow[400];
default:
return foregroundMuted;
}
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
@@ -205,21 +135,11 @@ function ControlledStatusBar({
modelOptions,
selectedModelId,
onSelectModel,
onSelectProviderAndModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled = false,
isModelLoading = false,
providerDefinitions,
allProviderModels,
canSelectModelProvider,
favoriteKeys = new Set<string>(),
onToggleFavoriteModel,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
@@ -261,14 +181,13 @@ function ControlledStatusBar({
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length) ||
Boolean(features?.length);
Boolean(thinkingOptions?.length);
if (!hasAnyControl) {
return null;
}
const modelDisabled = disabled;
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
const SEARCH_THRESHOLD = 6;
@@ -284,27 +203,6 @@ function ControlledStatusBar({
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions],
);
const fallbackAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (!modelOptions || modelOptions.length === 0) {
return map;
}
map.set(
provider,
modelOptions.map((option) => ({
provider: provider as AgentProvider,
id: option.id,
label: option.label,
})),
);
return map;
}, [modelOptions, provider]);
const effectiveProviderDefinitions =
providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions],
@@ -340,11 +238,8 @@ function ControlledStatusBar({
const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null);
if (!nextOpen) {
onDropdownClose?.();
}
},
[onDropdownClose],
[],
);
const handleSelectorPress = useCallback(
@@ -392,38 +287,49 @@ function ControlledStatusBar({
) : null}
{canSelectModel ? (
<Tooltip
key={`model-${displayModel}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
<>
<Tooltip
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
@@ -520,105 +426,6 @@ function ControlledStatusBar({
/>
</>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</>
) : (
<>
@@ -645,42 +452,73 @@ function ControlledStatusBar({
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? (
<View style={styles.sheetSection}>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (onSelectProviderAndModel) {
onSelectProviderAndModel(selectedProviderId, modelId);
} else {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
pointerEvents="none"
testID="agent-preferences-model"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
)}
/>
<DropdownMenu
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
@@ -701,7 +539,6 @@ function ControlledStatusBar({
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
@@ -762,83 +599,6 @@ function ControlledStatusBar({
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
</AdaptiveModalSheet>
</>
)}
@@ -848,12 +608,7 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export const AgentStatusBar = memo(function AgentStatusBar({
agentId,
serverId,
onDropdownClose,
}: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -864,9 +619,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
currentModeId: currentAgent.currentModeId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
}
: null;
}),
@@ -878,35 +631,28 @@ export const AgentStatusBar = memo(function AgentStatusBar({
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(serverId);
const modelsQuery = useQuery({
queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const snapshotModels = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {
return null;
}
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
return entry?.models ?? null;
}, [snapshotEntries, agent?.provider]);
const models = snapshotModels;
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [agent?.provider]);
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && snapshotModels) {
map.set(agent.provider, snapshotModels);
}
return map;
}, [agent?.provider, snapshotModels]);
const models = modelsQuery.data ?? null;
const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -930,13 +676,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const favoriteKeys = useMemo(
() =>
new Set(
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
),
[preferences.favoriteModels],
);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
@@ -953,13 +692,9 @@ export const AgentStatusBar = memo(function AgentStatusBar({
<ControlledStatusBar
provider={agent.provider}
modeOptions={
modeOptions.length > 0
? modeOptions
: [{ id: agent.currentModeId ?? "", label: displayMode }]
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels}
onSelectMode={(modeId) => {
if (!client) {
return;
@@ -974,85 +709,25 @@ export const AgentStatusBar = memo(function AgentStatusBar({
if (!client) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences((current) =>
toggleFavoriteModel({ preferences: current, provider, modelId }),
).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
if (!client) {
return;
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
features={agent.features}
onSetFeature={(featureId, value) => {
if (!client) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agent.provider,
updates: {
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist feature preference failed", error);
});
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={snapshotIsLoading || snapshotIsFetching}
onModelSelectorOpen={invalidateSnapshot}
onDropdownClose={onDropdownClose}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
);
});
}
export function DraftAgentStatusBar({
providerDefinitions,
@@ -1071,14 +746,9 @@ export function DraftAgentStatusBar({
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -1093,13 +763,6 @@ export function DraftAgentStatusBar({
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const favoriteKeys = useMemo(
() =>
new Set(
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
),
[preferences.favoriteModels],
);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
@@ -1114,18 +777,8 @@ export function DraftAgentStatusBar({
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => {
void updatePreferences((current) =>
toggleFavoriteModel({ preferences: current, provider, modelId }),
).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
isLoading={isAllModelsLoading}
disabled={disabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
/>
<ControlledStatusBar
provider={selectedProvider}
@@ -1135,51 +788,40 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled}
/>
</View>
);
}
const modelOptions: StatusOption[] = models.map((model) => ({
id: model.id,
label: model.label,
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}));
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}
return (
<>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => onSelectModel(modelId)}
onSelectProviderAndModel={onSelectProviderAndModel}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences((current) =>
toggleFavoriteModel({ preferences: current, provider, modelId }),
).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onModelSelectorOpen={onModelSelectorOpen}
disabled={disabled}
/>
</>
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
isModelLoading={isModelLoading}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
);
}
@@ -1,7 +1,6 @@
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
@@ -22,21 +21,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case "fast_mode":
return "yellow";
case "plan_mode":
return "blue";
default:
return "default";
}
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
@@ -52,7 +36,8 @@ export function resolveAgentModelSelection(input: {
: null;
const preferredModelId =
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const fallbackModel =
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const selectedModel =
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
+223 -144
View File
@@ -9,8 +9,8 @@ import {
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import Animated, {
@@ -27,7 +27,6 @@ import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import {
AssistantMessage,
SpeakMessage,
UserMessage,
ActivityLog,
ToolCall,
@@ -37,13 +36,9 @@ import {
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentPermissionAction,
AgentPermissionResponse,
} from "@server/server/agent/agent-sdk-types";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -64,7 +59,9 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -110,7 +107,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const streamRenderStrategy = useMemo(
() =>
resolveStreamRenderStrategy({
@@ -183,7 +180,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route);
router.navigate(route as any);
return;
}
@@ -272,6 +269,44 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[looseGap, tightGap],
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -331,10 +366,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
/>
);
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
@@ -366,18 +400,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (payload.source === "agent") {
const data = payload.data;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
);
}
return (
<ToolCall
toolName={data.name}
@@ -710,35 +732,12 @@ function PermissionRequestCard({
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { request } = permission;
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const description = request.description ?? "";
const resolvedActions = useMemo((): AgentPermissionAction[] => {
if (request.kind === "question") {
return [];
}
if (Array.isArray(request.actions) && request.actions.length > 0) {
return request.actions;
}
return [
{
id: "reject",
label: "Deny",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "accept",
label: isPlanRequest ? "Implement" : "Accept",
behavior: "allow",
variant: "primary",
},
];
}, [isPlanRequest, request]);
const planMarkdown = useMemo(() => {
if (!request) {
@@ -756,6 +755,90 @@ function PermissionRequestCard({
return undefined;
}, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
@@ -779,11 +862,11 @@ function PermissionRequestCard({
isPending: isResponding,
} = permissionMutation;
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
useEffect(() => {
resetPermissionMutation();
setRespondingActionId(null);
setRespondingAction(null);
}, [permission.request.id, resetPermissionMutation]);
const handleResponse = useCallback(
(response: AgentPermissionResponse) => {
@@ -797,24 +880,6 @@ function PermissionRequestCard({
},
[permission.agentId, permission.request.id, respondToPermission],
);
const handleActionPress = useCallback(
(action: AgentPermissionAction) => {
setRespondingActionId(action.id);
if (action.behavior === "allow") {
handleResponse({
behavior: "allow",
selectedActionId: action.id,
});
return;
}
handleResponse({
behavior: "deny",
selectedActionId: action.id,
message: "Denied by user",
});
},
[handleResponse],
);
if (request.kind === "question") {
return (
@@ -826,79 +891,6 @@ function PermissionRequestCard({
);
}
const footer = (
<>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
>
How would you like to proceed?
</Text>
<View
style={[
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
const testID =
action.behavior === "deny"
? "permission-request-deny"
: action.id === "accept" || action.id === "implement"
? "permission-request-accept"
: `permission-request-action-${action.id}`;
return (
<Pressable
key={action.id}
testID={testID}
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => handleActionPress(action)}
disabled={isResponding}
>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>
{action.label}
</Text>
</View>
)}
</Pressable>
);
})}
</View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
footer={footer}
disableOuterSpacing
/>
);
}
return (
<View
style={[
@@ -918,7 +910,16 @@ function PermissionRequestCard({
) : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
@@ -934,7 +935,78 @@ function PermissionRequestCard({
/>
) : null}
{footer}
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
>
How would you like to proceed?
</Text>
<View
style={[
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
<Pressable
testID="permission-request-deny"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
disabled={isResponding}
>
{respondingAction === "deny" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={permissionStyles.optionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
Deny
</Text>
</View>
)}
</Pressable>
<Pressable
testID="permission-request-accept"
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}}
disabled={isResponding}
>
{respondingAction === "accept" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={permissionStyles.optionContent}>
<Check size={14} color={theme.colors.foreground} />
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
Accept
</Text>
</View>
)}
</Pressable>
</View>
</View>
);
}
@@ -1055,7 +1127,14 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
...theme.shadow.sm,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
scrollToBottomIcon: {
color: theme.colors.foreground,
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
@@ -132,25 +128,6 @@ describe("estimateStreamItemHeight", () => {
expect(estimateStreamItemHeight(item)).toBe(220);
});
it("uses cached assistant image metadata when available", () => {
clearAssistantImageMetadataCache();
setAssistantImageMetadata(
{
source: "https://example.com/tall.png",
},
{ width: 800, height: 1600 },
);
const item: StreamItem = {
kind: "assistant_message",
id: "a-image",
text: "Look at this\n\n![Screenshot](https://example.com/tall.png)",
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
});
});
describe("web virtualization test overrides", () => {
@@ -1,5 +1,4 @@
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
@@ -46,7 +45,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
return 220;
case "tool_call":
return 136;
case "thought":
@@ -1,123 +0,0 @@
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
interface BranchSwitcherProps {
currentBranchName: string | null;
title: string;
serverId: string;
workspaceId: string;
isGitCheckout: boolean;
}
export function BranchSwitcher({
currentBranchName,
title,
serverId,
workspaceId,
isGitCheckout,
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
const queryClient = useQueryClient();
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
client,
normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
currentBranchName,
isGitCheckout,
isConnected,
toast,
queryClient,
});
if (!currentBranchName) {
return (
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
);
}
return (
<View ref={anchorRef} collapsable={false}>
<Pressable
testID="workspace-header-branch-switcher"
onPress={() => setIsOpen(true)}
style={({ hovered, pressed }) => [
styles.branchSwitcherTrigger,
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
]}
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={branchOptions}
value={currentBranchName}
onSelect={handleBranchSelect}
searchable
placeholder="Switch branch..."
searchPlaceholder="Filter branches..."
emptyText="No branches found."
title="Switch branch"
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={280}
renderOption={({ option, selected, active, onPress }) => (
<ComboboxItem
key={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
/>
)}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
flexShrink: 1,
},
branchSwitcherTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
minWidth: 0,
},
branchSwitcherTriggerHovered: {
backgroundColor: theme.colors.surface1,
},
}));
@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
describe("combined model selector helpers", () => {
const providerDefinitions = [
{
id: "claude",
label: "Claude",
description: "Claude provider",
defaultModeId: "default",
modes: [],
},
{
id: "codex",
label: "Codex",
description: "Codex provider",
defaultModeId: "auto",
modes: [],
},
];
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet-4.6",
label: "Sonnet 4.6",
},
];
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
];
it("keeps enough data to search by model and provider name", async () => {
const rows = buildModelRows(
providerDefinitions,
new Map([
["claude", claudeModels],
["codex", codexModels],
]),
);
expect(rows).toEqual([
expect.objectContaining({
providerLabel: "Claude",
modelLabel: "Sonnet 4.6",
modelId: "sonnet-4.6",
}),
expect.objectContaining({
providerLabel: "Codex",
modelLabel: "GPT-5.4",
modelId: "gpt-5.4",
}),
]);
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
});
it("builds an explicit trigger label for the selected provider and model", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
});
});
@@ -1,74 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = Platform.OS === "web";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
type SelectorModelRow,
} from "./combined-model-selector.utils";
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
const INLINE_MODEL_THRESHOLD = 8;
type SelectorView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
canSelectProvider?: (provider: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
renderTrigger?: (input: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
disabled?: boolean;
}
interface SelectorContentProps {
view: SelectorView;
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
}
type DrillDownView = { provider: string };
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
@@ -77,429 +18,14 @@ function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): s
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function partitionRows(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
const favoriteRows: SelectorModelRow[] = [];
const regularRows: SelectorModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favoriteRows.push(row);
continue;
}
regularRows.push(row);
}
return { favoriteRows, regularRows };
}
function sortFavoritesFirst(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): SelectorModelRow[] {
const favorites: SelectorModelRow[] = [];
const rest: SelectorModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favorites.push(row);
} else {
rest.push(row);
}
}
return [...favorites, ...rest];
}
function groupRowsByProvider(
rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
const grouped = new Map<
string,
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
>();
for (const row of rows) {
const existing = grouped.get(row.provider);
if (existing) {
existing.rows.push(row);
continue;
}
grouped.set(row.provider, {
providerId: row.provider,
providerLabel: row.providerLabel,
rows: [row],
});
}
return Array.from(grouped.values());
}
function ModelRow({
row,
isSelected,
isFavorite,
disabled = false,
elevated = false,
onPress,
onToggleFavorite,
}: {
row: SelectorModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
elevated?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(row.provider);
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onToggleFavorite?.(row.provider, row.modelId);
},
[onToggleFavorite, row.modelId, row.provider],
);
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
return (
<ComboboxItem
label={row.modelLabel}
description={showDescription ? row.description : undefined}
selected={isSelected}
disabled={disabled}
elevated={elevated}
onPress={onPress}
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={({ pressed, hovered }) => [
styles.favoriteButton,
hovered && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => (
<Star
size={16}
color={
isFavorite
? theme.colors.palette.amber[500]
: hovered
? theme.colors.foregroundMuted
: theme.colors.border
}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
)}
</Pressable>
) : null
}
/>
);
}
function FavoritesSection({
favoriteRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
if (favoriteRows.length === 0) {
return null;
}
return (
<View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
{favoriteRows.map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
elevated
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
</View>
);
}
function GroupedProviderRows({
providerDefinitions,
groupedRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
viewKind,
}: {
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) {
const { theme } = useUnistyles();
return (
<View>
{groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find(
(definition) => definition.id === group.providerId,
);
const ProvIcon = getProviderIcon(group.providerId);
const isInline = viewKind === "provider";
return (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
function ProviderSearchInput({
value,
onChangeText,
autoFocus = false,
}: {
value: string;
onChangeText: (text: string) => void;
autoFocus?: boolean;
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus]);
return (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
);
}
function SelectorContent({
view,
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels),
[allProviderModels, providerDefinitions],
);
const scopedRows = useMemo(() => {
if (view.kind === "provider") {
return allRows.filter((row) => row.provider === view.providerId);
}
return allRows;
}, [allRows, view]);
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const visibleRows = useMemo(
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
[normalizedQuery, scopedRows],
);
const { favoriteRows, regularRows } = useMemo(
() => partitionRows(visibleRows, favoriteKeys),
[favoriteKeys, visibleRows],
);
// Group ALL visible rows by provider — favorites are a cross-cutting view,
// not a partition. A model being favorited doesn't remove it from its provider.
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
// When searching at Level 1, filter grouped rows to only providers whose name or models match
const filteredGroupedRows = useMemo(() => {
if (view.kind === "provider" || !normalizedQuery) {
return allGroupedRows;
}
return allGroupedRows.filter(
(group) =>
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
);
}, [allGroupedRows, normalizedQuery, view.kind]);
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
return (
<View>
{view.kind === "all" ? (
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
) : null}
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
viewKind={view.kind}
/>
) : null}
{!hasResults ? (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
function ProviderBackButton({
providerId,
providerLabel,
onBack,
}: {
providerId: string;
providerLabel: string;
onBack?: () => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
if (!onBack) {
return null;
}
return (
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
);
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
disabled?: boolean;
}
export function CombinedModelSelector({
@@ -509,103 +35,48 @@ export function CombinedModelSelector({
selectedModel,
onSelect,
isLoading,
canSelectProvider = () => true,
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
onOpen,
onClose,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [searchQuery, setSearchQuery] = useState("");
// Single-provider mode: only one provider with models → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => {
const providers = Array.from(allProviderModels.keys());
if (providers.length !== 1) return null;
const providerId = providers[0]!;
const label = resolveProviderLabel(providerDefinitions, providerId);
return { kind: "provider", providerId, providerLabel: label };
}, [allProviderModels, providerDefinitions]);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
setView(singleProviderView ?? { kind: "all" });
if (open) {
onOpen?.();
const models = allProviderModels.get(selectedProvider);
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setSearchQuery("");
onClose?.();
}
},
[onOpen, onClose, singleProviderView],
[allProviderModels, selectedProvider],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView(singleProviderView ?? { kind: "all" });
setView("groups");
setSearchQuery("");
},
[onSelect, singleProviderView],
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedProviderLabel = useMemo(
() => resolveProviderLabel(providerDefinitions, selectedProvider),
[providerDefinitions, selectedProvider],
);
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider);
if (!models) {
return isLoading ? "Loading..." : "Select model";
}
const model = models.find((entry) => entry.id === selectedModel);
if (!models) return isLoading ? "Loading..." : "Select model";
const model = models.find((m) => m.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
const models = allProviderModels.get(view.providerId);
const modelCount = models?.length ?? 0;
return Math.min(80 + modelCount * 40, 400);
}, [allProviderModels, view]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => {
if (isWeb) {
return;
}
if (!isOpen) {
setIsContentReady(false);
return;
}
const frame = requestAnimationFrame(() => {
setIsContentReady(true);
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return (
<>
@@ -619,26 +90,14 @@ export function CombinedModelSelector({
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
]}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
>
{renderTrigger ? (
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: () => handleOpenChange(!isOpen),
disabled,
isOpen,
})
) : (
<>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{triggerLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={[]}
@@ -646,62 +105,184 @@ export function CombinedModelSelector({
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight}
title="Select model"
stickyHeader={
view.kind === "provider" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={() => {
setView({ kind: "all" });
setSearchQuery("");
}}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
/>
</View>
) : undefined
}
>
{isContentReady ? (
<SelectorContent
view={view}
{view === "groups" ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
onDrillDown={(provider) => {
setView({ provider });
setSearchQuery("");
}}
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
</View>
<DrillDownModelView
provider={view.provider}
providerDefinitions={providerDefinitions}
models={allProviderModels.get(view.provider) ?? []}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView("groups");
setSearchQuery("");
}}
/>
)}
</Combobox>
</>
);
}
function GroupsView({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>{def.label}</Text>
</View>
{models.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
selected={model.id === selectedModel && def.id === selectedProvider}
onPress={() => onSelect(def.id, model.id)}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(def.id)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{def.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{models.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
function DrillDownModelView({
provider,
providerDefinitions,
models,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
onSelect,
onBack,
}: {
provider: string;
providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void;
onBack: () => void;
}) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
<SearchInput
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
description={model.description}
selected={model.id === selectedModel && provider === selectedProvider}
onPress={() => onSelect(provider, model.id)}
/>
))}
{filteredModels.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
@@ -726,28 +307,17 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
height: "auto",
},
favoritesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
paddingVertical: theme.spacing[1],
},
sectionHeadingText: {
fontSize: theme.fontSize.xs,
@@ -761,7 +331,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 36,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
drillDownRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -771,8 +340,8 @@ const styles = StyleSheet.create((theme) => ({
},
drillDownText: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: "row",
@@ -783,11 +352,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
level2Header: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
@@ -796,10 +360,9 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
backButtonHovered: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
backButtonPressed: {
backgroundColor: theme.colors.surface2,
@@ -811,46 +374,9 @@ const styles = StyleSheet.create((theme) => ({
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
favoriteButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
favoriteButtonHovered: {
backgroundColor: theme.colors.surface2,
},
favoriteButtonPressed: {
backgroundColor: theme.colors.surface1,
},
sheetLoadingState: {
minHeight: 160,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[2],
},
sheetLoadingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
providerSearchContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[2],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
providerSearchInput: {
flex: 1,
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));
@@ -1,54 +0,0 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
export type SelectorModelRow = FavoriteModelRow;
export function resolveProviderLabel(
providerDefinitions: AgentProviderDefinition[],
providerId: string,
): string {
return (
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
);
}
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
return modelLabel;
}
export function buildModelRows(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): SelectorModelRow[] {
const providerLabelMap = new Map(
providerDefinitions.map((definition) => [definition.id, definition.label]),
);
const rows: SelectorModelRow[] = [];
for (const definition of providerDefinitions) {
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
for (const model of allProviderModels.get(definition.id) ?? []) {
rows.push({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
});
}
}
return rows;
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
if (!normalizedQuery) {
return true;
}
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
}
@@ -268,7 +268,10 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.lg,
shadowColor: "#000",
shadowOpacity: 0.4,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
},
header: {
paddingHorizontal: theme.spacing[4],
@@ -1,153 +0,0 @@
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ContextWindowMeterProps = {
maxTokens: number;
usedTokens: number;
};
const SVG_SIZE = 20;
const CENTER = SVG_SIZE / 2;
const RADIUS = 7;
const STROKE_WIDTH = 2.25;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function isValidUsedTokens(value: number): boolean {
return Number.isFinite(value) && value >= 0;
}
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
return null;
}
return (usedTokens / maxTokens) * 100;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
}
return { progress: theme.colors.foregroundMuted, track };
}
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
if (percentage === null) {
return null;
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={styles.container}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
>
<Svg
width={SVG_SIZE}
height={SVG_SIZE}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.track}
strokeWidth={STROKE_WIDTH}
/>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.progress}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
</Svg>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
svg: {
transform: [{ rotate: "-90deg" }],
},
tooltipContent: {
gap: theme.spacing[1],
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));
@@ -1,57 +0,0 @@
import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout";
/**
* VS Code-style titlebar drag region for Electron.
*
* Copied from VS Code at commit daa0a70:
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
*
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
* no state, no event listeners. Interactive elements get no-drag from their own
* CSS (global backstop in index.html). The drag region never re-renders.
*
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
* On macOS, Electron handles edge resize natively.
*/
/**
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
* Place as FIRST child of any positioned container that should be draggable.
*/
export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
return null;
}
return (
<>
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
<div
style={{
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
}}
/>
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
<div
style={{
position: "absolute",
top: 0,
width: "100%",
height: 4,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
}}
/>
</>
);
}
@@ -108,7 +108,11 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
...theme.shadow.md,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
textContainer: {
flex: 1,
@@ -20,7 +20,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -133,11 +133,8 @@ export function DraggableList<T>({
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const scrollViewRef = useRef<ScrollView>(null);
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar,
});
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -180,6 +177,7 @@ export function DraggableList<T>({
);
const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const wrapperStyle = [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
@@ -195,10 +193,12 @@ export function DraggableList<T>({
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={scrollbar.onLayout}
onContentSizeChange={scrollbar.onContentSizeChange}
onScroll={scrollbar.onScroll}
scrollEventThrottle={16}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
@@ -259,7 +259,13 @@ export function DraggableList<T>({
{ListFooterComponent}
</>
)}
{scrollbar.overlay}
<WebDesktopScrollbarOverlay
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}
onScrollToOffset={(nextOffset) => {
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
@@ -1,17 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
View,
Text,
Pressable,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -20,12 +13,10 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -48,7 +39,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -88,7 +79,6 @@ export function ExplorerSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
@@ -109,11 +99,6 @@ export function ExplorerSidebar({
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
const enableSidebarCloseGesture = isMobile && isOpen;
const handleTabPress = useCallback(
@@ -188,7 +173,7 @@ export function ExplorerSidebar({
});
if (shouldClose) {
animateToClose();
runOnJS(handleCloseFromGesture)();
runOnJS(handleClose)("swipe-close-gesture");
} else {
animateToOpen();
}
@@ -203,7 +188,7 @@ export function ExplorerSidebar({
backdropOpacity,
animateToOpen,
animateToClose,
handleCloseFromGesture,
handleClose,
isGesturing,
closeGestureRef,
closeTouchStartX,
@@ -264,17 +249,18 @@ export function ExplorerSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable
style={styles.backdropPressable}
onPress={() => handleClose("backdrop-press")}
/>
</Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
explorerStaticStyles.mobileSidebar,
{
width: windowWidth,
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
@@ -303,32 +289,25 @@ export function ExplorerSidebar({
}
return (
<Animated.View
style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</View>
</GestureDetector>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
</Animated.View>
);
}
@@ -357,14 +336,12 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const padding = useWindowControlsPadding("explorerSidebar");
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
@@ -419,28 +396,24 @@ function SidebarContent({
);
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
const styles = StyleSheet.create((theme) => ({
backdrop: {
...RNStyleSheet.absoluteFillObject,
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute" as const,
position: "absolute",
top: 0,
right: 0,
bottom: 0,
overflow: "hidden" as const,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
},
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
desktopSidebarBorder: {
position: "relative",
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
@@ -459,7 +432,6 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
header: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
@@ -481,7 +453,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
tabActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
backgroundColor: theme.colors.surface1,
},
tabText: {
fontSize: theme.fontSize.sm,
+242 -160
View File
@@ -1,17 +1,19 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
FlatList,
ListRenderItemInfo,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
Text,
View,
Platform,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import Animated, {
cancelAnimation,
Easing,
@@ -24,17 +26,18 @@ import Animated, {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
File,
FileText,
Folder,
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -51,7 +54,10 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
@@ -59,7 +65,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "size", label: "Size" },
];
const INDENT_PER_LEVEL = 16;
const INDENT_PER_LEVEL = 12;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
@@ -90,7 +96,7 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts();
@@ -118,22 +124,18 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing,
requestFileDownloadToken,
selectExplorerEntry,
} = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
[expandedPathsArray],
);
const directories = explorerState?.directories ?? new Map();
const pendingRequest = explorerState?.pendingRequest ?? null;
@@ -149,16 +151,16 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
const treeListRef = useRef<FlatList<TreeRow>>(null);
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
enabled: showDesktopWebScrollbar,
});
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
const hasInitializedRef = useRef(false);
useEffect(() => {
hasInitializedRef.current = false;
}, [workspaceStateKey]);
setExpandedPaths(new Set(["."]));
}, [actionsWorkspaceStateKey]);
useEffect(() => {
if (!hasWorkspaceScope) {
@@ -172,33 +174,23 @@ export function FileExplorerPane({
recordHistory: false,
setCurrentPath: false,
});
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
}, [hasWorkspaceScope, requestDirectoryListing]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => {
if (!selectedEntryPath || !workspaceStateKey) {
if (!selectedEntryPath || !hasWorkspaceScope) {
return;
}
const parentDir = getParentDirectory(selectedEntryPath);
const ancestors = getAncestorDirectories(parentDir);
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
if (newPaths.length === 0) {
return;
}
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
newPaths.forEach((path) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
ancestors.forEach((path) => next.add(path));
return next;
});
ancestors.forEach((path) => {
if (!directories.has(path)) {
void requestDirectoryListing(path, {
recordHistory: false,
@@ -206,43 +198,34 @@ export function FileExplorerPane({
});
}
});
}, [
directories,
workspaceStateKey,
expandedPaths,
requestDirectoryListing,
selectedEntryPath,
setExpandedPathsForWorkspace,
]);
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => {
if (!workspaceStateKey) {
if (!hasWorkspaceScope) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
} else {
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
const nextExpanded = !isExpanded;
setExpandedPaths((prev) => {
const next = new Set(prev);
if (isExpanded) {
next.delete(entry.path);
} else {
next.add(entry.path);
}
return next;
});
if (nextExpanded && !directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
}
},
[
workspaceStateKey,
expandedPaths,
directories,
requestDirectoryListing,
setExpandedPathsForWorkspace,
],
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const handleOpenFile = useCallback(
@@ -401,6 +384,7 @@ export function FileExplorerPane({
({ item }: ListRenderItemInfo<TreeRow>) => {
const entry = item.entry;
const depth = item.depth;
const displayKind = getEntryDisplayKind(entry);
const isDirectory = entry.kind === "directory";
const isExpanded = isDirectory && expandedPaths.has(entry.path);
const isSelected = selectedEntryPath === entry.path;
@@ -415,30 +399,16 @@ export function FileExplorerPane({
(hovered || pressed || isSelected) && styles.entryRowActive,
]}
>
{depth > 0 &&
Array.from({ length: depth }, (_, i) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
{loading ? (
<ActivityIndicator size="small" />
) : (
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
renderEntryIcon(isDirectory ? "directory" : displayKind, {
foreground: theme.colors.foregroundMuted,
primary: theme.colors.primary,
directoryOpen: isExpanded,
})
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -520,6 +490,24 @@ export function FileExplorerPane({
});
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
(event: LayoutChangeEvent) => {
if (showDesktopWebScrollbar) {
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -564,31 +552,27 @@ export function FileExplorerPane({
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={({ hovered, pressed }) => [
styles.sortTrigger,
(hovered || pressed) && styles.sortTriggerHovered,
]}
>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
ref={treeListRef}
@@ -598,22 +582,126 @@ export function FileExplorerPane({
keyExtractor={(row) => row.entry.path}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
onContentSizeChange={
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
{scrollbar.overlay}
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={treeScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
treeListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
</View>
)}
</View>
);
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return colors.directoryOpen ? (
<FolderOpen size={18} color={colors.primary} />
) : (
<Folder size={18} color={colors.primary} />
);
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
if (entry.kind === "directory") {
return "directory";
}
const extension = getExtension(entry.name);
if (extension === null) {
return "other";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "other";
}
function getExtension(name: string): string | null {
const index = name.lastIndexOf(".");
if (index === -1 || index === name.length - 1) {
return null;
}
return name.slice(index + 1).toLowerCase();
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
@@ -754,38 +842,50 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
sortTrigger: {
paneHeaderLeft: {
flex: 1,
minWidth: 0,
},
paneHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
sortButton: {
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
marginLeft: theme.spacing[3] - theme.spacing[1],
paddingHorizontal: theme.spacing[1],
height: 24,
borderRadius: theme.borderRadius.base,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
sortTriggerText: {
fontSize: theme.fontSize.xs,
sortButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
treeList: {
flex: 1,
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
centerState: {
@@ -836,16 +936,8 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
entryInfo: {
@@ -855,16 +947,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: {
flexShrink: 0,
},
+59 -21
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -7,12 +7,17 @@ import {
Text,
View,
Platform,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import {
highlightCode,
darkHighlightColors,
@@ -114,14 +119,13 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark";
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
@@ -136,6 +140,24 @@ function FilePreviewBody({
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
@@ -175,11 +197,13 @@ function FilePreviewBody({
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
@@ -194,7 +218,13 @@ function FilePreviewBody({
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
@@ -206,11 +236,13 @@ function FilePreviewBody({
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNImage
source={{
@@ -220,7 +252,13 @@ function FilePreviewBody({
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
<WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}}
/>
</View>
);
}
@@ -242,7 +280,7 @@ export function FilePane({
workspaceRoot: string;
filePath: string;
}) {
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
@@ -15,7 +15,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
@@ -26,11 +25,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
pull: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
@@ -62,102 +56,89 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
}
describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
});
it("prioritizes pull when the branch is behind origin", () => {
const actions = buildGitActions(
it("keeps the secondary menu order stable while the primary action changes", () => {
const noPrActions = buildGitActions(createInput());
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
expect(noPrActions.primary).toBeNull();
expect(withPrActions.primary?.id).toBe("push");
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 1,
}),
);
const pushAction = actions.secondary.find((action) => action.id === "push");
it("disables hidden-before actions with explanations instead", () => {
const actions = buildGitActions(createInput());
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
expect(pushAction).toMatchObject({
disabled: false,
unavailableMessage:
"Push isn't available yet because there are newer changes to bring in first",
expect(actionById.get("push")).toMatchObject({
disabled: true,
description: "No remote configured",
});
});
it("shows update-from-base only on feature branches that are behind the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
behindBaseCount: 3,
}),
);
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(updateAction).toMatchObject({
label: "Update from main",
disabled: false,
unavailableMessage: undefined,
expect(actionById.get("pr")).toMatchObject({
label: "Create PR",
disabled: true,
description: "Branch has no commits ahead of main",
});
});
it("uses a clear sentence when pull is unavailable", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const pullAction = actions.secondary.find((action) => action.id === "pull");
expect(pullAction).toMatchObject({
disabled: false,
unavailableMessage: "Pull isn't available because this branch is already up to date",
expect(actionById.get("merge-branch")).toMatchObject({
disabled: true,
description: "No commits to merge into main",
});
expect(actionById.get("merge-from-base")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.has("archive-worktree")).toBe(false);
});
it("keeps update-from-base off the base branch entirely", () => {
it("keeps the current primary action visible in the menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
});
it("keeps feature branch actions available off the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 1,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
}),
);
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"merge-from-base",
"merge-branch",
"pr",
]);
expect(actions.primary?.id).toBe("pr");
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
}),
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(syncAction).toMatchObject({
label: "Sync",
disabled: true,
description: "Already up to date",
});
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
+57 -120
View File
@@ -4,7 +4,6 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "pull"
| "push"
| "pr"
| "merge-branch"
@@ -18,7 +17,7 @@ export interface GitAction {
successLabel: string;
disabled: boolean;
status: ActionStatus;
unavailableMessage?: string;
description?: string;
icon?: ReactElement;
handler: () => void;
}
@@ -48,7 +47,6 @@ export interface BuildGitActionsInput {
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
@@ -56,8 +54,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -77,26 +74,14 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime.commit.handler,
});
allActions.set("pull", {
id: "pull",
label: "Pull",
pendingLabel: "Pulling...",
successLabel: "Pulled",
disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
handler: input.runtime.pull.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled,
disabled: input.runtime.push.disabled || !input.hasRemote,
status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
description: input.hasRemote ? undefined : "No remote configured",
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
@@ -108,25 +93,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled,
disabled:
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
status: input.runtime["merge-branch"].status,
unavailableMessage: input.runtime["merge-branch"].disabled
? undefined
: getMergeBranchUnavailableMessage(input),
description: getMergeBranchDescription(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Update from ${input.baseRefLabel}`,
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled,
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
status: input.runtime["merge-from-base"].status,
unavailableMessage: input.runtime["merge-from-base"].disabled
? undefined
: getMergeFromBaseUnavailableMessage(input),
description: getMergeFromBaseDescription(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
@@ -136,32 +121,21 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled,
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
status: input.runtime["archive-worktree"].status,
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondaryIds = [...REMOTE_ACTION_IDS];
if (!input.isOnBaseBranch) {
secondaryIds.push(...FEATURE_ACTION_IDS);
}
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
secondary.push(allActions.get("archive-worktree")!);
}
return {
primary,
secondary: secondaryIds.map((id) => allActions.get(id)!),
menu: [],
};
return { primary, secondary, menu: [] };
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
@@ -171,19 +145,20 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.hasUncommittedChanges) {
return "commit";
}
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
if (input.aheadOfOrigin > 0 && input.hasRemote) {
return "push";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
if (
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
}
return null;
@@ -196,12 +171,9 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled,
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
status: input.runtime.pr.status,
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: "View PR isn't available right now because GitHub isn't connected",
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
@@ -212,100 +184,65 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: input.runtime.pr.disabled,
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
status: input.runtime.pr.status,
unavailableMessage: input.runtime.pr.disabled
? undefined
: getCreatePrUnavailableMessage(input),
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
function canPush(input: BuildGitActionsInput): boolean {
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
}
function canMergeBranch(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.aheadCount > 0
);
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.behindBaseCount > 0
);
}
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
return false;
}
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";
return false;
}
if (input.hasUncommittedChanges) {
return "Pull isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0) {
return "Pull isn't available because this branch is already up to date";
}
return undefined;
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Push isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin > 0) {
return "Push isn't available yet because there are newer changes to bring in first";
}
if (input.aheadOfOrigin === 0) {
return "Push isn't available because there is nothing new to send";
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected";
return "GitHub features unavailable";
}
if (input.aheadCount === 0) {
return "Create PR isn't available because this branch doesn't have any new commits yet";
return `Branch has no commits ahead of ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Merge isn't available because we couldn't determine the base branch";
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Merge isn't available while you have local changes so commit or stash them first";
return "Requires clean working tree";
}
if (input.aheadCount === 0) {
return "Merge isn't available because this branch doesn't have anything new to merge yet";
return `No commits to merge into ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Update isn't available because we couldn't determine the base branch";
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Update isn't available while you have local changes so commit or stash them first";
return "Requires clean working tree";
}
if (input.behindBaseCount === 0) {
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
if (!input.isOnBaseBranch) {
return undefined;
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
}
return undefined;
}
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import { ChevronDown, MoreVertical } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,7 +11,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
@@ -20,7 +19,6 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
@@ -29,20 +27,6 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
return action.label;
}, []);
const handleActionSelect = useCallback(
(action: GitAction) => {
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: <Info size={16} color={theme.colors.foreground} />,
});
return;
}
action.handler();
},
[theme.colors.foreground, toast],
);
return (
<View style={styles.row}>
{gitActions.primary ? (
@@ -89,8 +73,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator =
action.id === "merge-from-base" || action.id === "archive-worktree";
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
@@ -98,12 +81,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined
action.id === "archive-worktree" && archiveShortcutKeys
? <Shortcut chord={archiveShortcutKeys} />
: undefined
}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
@@ -112,7 +94,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
action.id === "pr" &&
action.label === "View PR"
}
onSelect={() => handleActionSelect(action)}
description={action.description}
onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
@@ -142,12 +125,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={false}
onSelect={() => handleActionSelect(action)}
onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,15 @@
import type { ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
borderless?: boolean;
}
interface SidebarMenuToggleProps {
@@ -44,7 +42,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
@@ -77,7 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -92,7 +90,6 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
right={rightContent}
leftStyle={styles.left}
borderless={borderless}
/>
);
}
@@ -1,42 +1,40 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
useIsCompactFormFactor,
getIsDesktopMac,
} from "@/constants/layout";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps {
left?: ReactNode;
right?: ReactNode;
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
}
/**
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
borderless,
}: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const padding = useWindowControlsPadding("header");
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const trafficLightPadding = useTrafficLightPadding();
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
return (
<View style={styles.header}>
@@ -44,14 +42,10 @@ export function ScreenHeader({
<View
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
]}
{...dragHandlers}
>
<TitlebarDragRegion />
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
</View>
@@ -66,7 +60,6 @@ const styles = StyleSheet.create((theme) => ({
},
inner: {},
row: {
position: "relative",
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
@@ -90,7 +83,4 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
borderless: {
borderBottomColor: "transparent",
},
}));
@@ -1,14 +0,0 @@
import type { ReactNode } from "react";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
const storeReady = useStoreReady();
const bootstrapState = useHostRuntimeBootstrapState();
if (!storeReady) {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}
return <>{children}</>;
}
@@ -1,18 +0,0 @@
import Svg, { Path } from "react-native-svg";
interface CopilotIconProps {
size?: number;
color?: string;
}
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
<Path
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fillRule="nonzero"
/>
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
</Svg>
);
}

Some files were not shown because too many files have changed in this diff Show More