Compare commits

..
423 changed files with 8653 additions and 34260 deletions
+2 -13
View File
@@ -5,18 +5,8 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
merge_group:
workflow_dispatch: workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# CI does not use the CUDA execution provider, and the onnxruntime-node
# postinstall download from NuGet is large enough to make npm ci flaky.
ONNXRUNTIME_NODE_INSTALL: skip
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -104,8 +94,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install agent CLIs for provider tests - name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency - name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight run: npm run build --workspace=@getpaseo/highlight
@@ -118,7 +108,6 @@ jobs:
env: env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests: desktop-tests:
strategy: strategy:
+2 -2
View File
@@ -40,7 +40,7 @@ on:
rollout_hours: rollout_hours:
description: "Linear rollout duration in hours. Use 0 for instant rollout." description: "Linear rollout duration in hours. Use 0 for instant rollout."
required: false required: false
default: "36" default: "24"
type: string type: string
concurrency: concurrency:
@@ -51,7 +51,7 @@ env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }} CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }} SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }} ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
DESKTOP_PACKAGE_PATH: "packages/desktop" DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs: jobs:
+72
View File
@@ -0,0 +1,72 @@
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/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || 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: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates (main push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
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
-60
View File
@@ -1,60 +0,0 @@
name: Nix Update Hash
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-update-hash.yml"
permissions:
contents: read
jobs:
update-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.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: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates
run: |
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
git config user.name "paseo-ai[bot]"
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
git add package-lock.json nix/npm-deps.hash
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
git push
-99
View File
@@ -1,99 +0,0 @@
name: Nix
on:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- 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: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Smoke Nix daemon
run: |
set -euo pipefail
export PASEO_HOME
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill "$DAEMON_PID"
wait "$DAEMON_PID" || true
fi
rm -rf "$PASEO_HOME"
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Nix daemon exited before becoming reachable."
break
fi
sleep 1
done
echo "::group::daemon.log"
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon status"
./result/bin/paseo daemon status || true
echo "::endgroup::"
exit 1
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop
-1
View File
@@ -6,7 +6,6 @@ build/
dist/ dist/
.next/ .next/
out/ out/
result
# Environment variables # Environment variables
.env .env
+604 -749
View File
File diff suppressed because it is too large Load Diff
+20 -23
View File
@@ -21,28 +21,26 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`. At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it | | Doc | What's in it |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going | | [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow | | [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track | | [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | | [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms | | [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | | [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons | | [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | | [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | | [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start ## Quick start
@@ -88,7 +86,6 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site. - **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal. - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable. - **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
+52 -21
View File
@@ -1,32 +1,63 @@
# Contributing to Paseo # Contributing to Paseo
Paseo is an opinionated product maintained by one person. ## How this project works
I read every issue and PR myself, and I am selective about what contributions I accept. Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo. - **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
Core product, design, architecture, and workflow changes are not accepted. ## Reporting bugs
Follow these rules if you want your PR to be merged: Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
- Keep it to one focused change - **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
- Link to an issue - **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
- Explain the problem you're solving - **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
- Include repro steps if it's a bug - **One bug per issue.** Three findings, three issues.
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
Your PR will be closed if you do any of these: ## Before you start
- Bundle unrelated changes - [README.md](README.md)
- Fail basic checks like typecheck, formatting or linting - [docs/architecture.md](docs/architecture.md)
- Make product, design, or architecture changes without prior discussion - [docs/development.md](docs/development.md)
- Submit no evidence of testing - [docs/coding-standards.md](docs/coding-standards.md)
- Skip the linked issue - [docs/testing.md](docs/testing.md)
- Clearly fully AI-generated PR - [CLAUDE.md](CLAUDE.md)
## AI assistance ## What is most helpful
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed. - bug fixes (especially Windows and Linux)
- regression fixes
- doc improvements
- packaging and platform fixes
- focused UX improvements that fit the product direction
- tests that lock down important behavior
## Development setup
```bash
npm run dev # daemon + expo
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
```
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
## Pull requests
- One focused change per PR. Split unrelated cleanups out.
- Reference the issue you're fixing, unless it's a small objective bug.
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
- `npm run typecheck` and `npm run lint` must pass.
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
## Forks are fine
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
+14 -18
View File
@@ -27,12 +27,12 @@ Or from `packages/app`:
```bash ```bash
# Debug # Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive APP_VARIANT=development npx expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug APP_VARIANT=development npx expo run:android --variant=debug
# Release # Release
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive APP_VARIANT=production npx expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release APP_VARIANT=production npx expo run:android --variant=release
# Clear generated Android project # Clear generated Android project
rm -rf android rm -rf android
@@ -48,28 +48,24 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger: Stable tag pushes like `v0.1.0` trigger:
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it. - `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). - `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag. `android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
### Useful commands ### Useful commands
```bash ```bash
cd packages/app cd packages/app
# Recent builds # List recent workflow runs
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}' npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page, # Inspect a run
# which has a Submissions section showing the auto-submit to the Play Store). npx eas workflow:view <run-id>
npx eas build:view <build-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
``` ```
The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.
See [docs/release.md](release.md) for the full mobile-build babysitting flow.
+1 -4
View File
@@ -107,7 +107,7 @@ Enables remote access when the daemon is behind a firewall.
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content - Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client - Pairing via QR code transfers the daemon's public key to the client
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS` - Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`
See [SECURITY.md](../SECURITY.md) for the full threat model. See [SECURITY.md](../SECURITY.md) for the full threat model.
@@ -146,8 +146,6 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:** **Notable session message types:**
- `agent_update` — Agent state changed (status, title, labels) - `agent_update` — Agent state changed (status, title, labels)
@@ -207,7 +205,6 @@ initializing → idle ⇄ running
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers - **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items - Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to all subscribed clients in real time - Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record) - Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
-38
View File
@@ -59,30 +59,6 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`) - `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI - `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
--- ---
## Z.AI (Zhipu) coding plan ## Z.AI (Zhipu) coding plan
@@ -366,14 +342,6 @@ Required fields for ACP providers:
- `label` - `label`
- `command` — the command to spawn the agent process (must support ACP over stdio) - `command` — the command to spawn the agent process (must support ACP over stdio)
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
### Example: Google Gemini CLI ### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag. [Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
@@ -536,12 +504,6 @@ Each entry in the `models` array:
| `description` | `string` | No | Short description | | `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option | | `isDefault` | `boolean` | No | Mark as the default thinking option |
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
### Gotcha: `extends: "claude"` with third-party endpoints ### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors. When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
+3 -8
View File
@@ -140,7 +140,7 @@ Single file, validated with `PersistedConfigSchema`.
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
mcp: { enabled: boolean, injectIntoAgents: boolean }, mcp: { enabled: boolean, injectIntoAgents: boolean },
cors: { allowedOrigins: string[] }, cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
auth: { password: string } // bcrypt hash, optional auth: { password: string } // bcrypt hash, optional
}, },
app: { app: {
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
providers: Record<providerId, ProviderOverride> providers: Record<providerId, ProviderOverride>
}, },
features: { features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
}, },
log: { log: {
level, format, level, format,
@@ -362,11 +362,6 @@ Array of project records.
| `updatedAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
emptied duplicate.
--- ---
## 7. Workspace Registry ## 7. Workspace Registry
+1 -1
View File
@@ -216,7 +216,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard. - Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong. - Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only. - Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately. - Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Bespoke status pills. `<StatusBadge>` is the pill primitive. - Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive. - Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive. - Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
+1 -14
View File
@@ -37,22 +37,9 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In any worktree-style or portless setup, never assume default ports. In any worktree-style or portless setup, never assume default ports.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### Daemon logs ### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set Check `$PASEO_HOME/daemon.log` for trace-level logs.
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts ## paseo.json service scripts
-138
View File
@@ -1,138 +0,0 @@
# Hover
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
## The pattern
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
```tsx
//
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
// │
<View
style={styles.workspaceRowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable // ┐ Separate inner Pressable.
onPress={handlePress} // │ Handles press only.
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
onPressOut={...} // ┘
style={workspaceRowStyle}
>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>…</View>
<WorkspaceRowRightGroup isHovered={isHovered} />
{/* └─ Reveals content based on hover state. */}
</View>
</Pressable>
</View>
```
Five things make this work. Every one of them matters.
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
That's the whole pattern. Internalize it.
## When you skip the pattern, here is what breaks
### Failure mode 1 — Nested Pressables fight over hover state
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
### Failure mode 2 — The hovered state changes the trigger's geometry
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
Common variants:
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
- Hover state swaps the trigger for a different element type, remounting it.
Fixes, in preferred order:
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
### Failure mode 3 — Revealed content lives outside the hover trigger
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
Wrong:
```tsx
<View>
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
<Bubble />
</View>
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
</View>
```
Right:
```tsx
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
<Bubble />
<TrailingRow /> {/* INSIDE — child */}
</View>
```
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
```tsx
const showControls = isHovered || isNative || isCompact;
```
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
## What about `Pressable.onHoverIn` / `onHoverOut`?
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
## Real gaps with floating panels
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
## Pre-PR checklist
Before opening a PR that touches hover:
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
-49
View File
@@ -1,49 +0,0 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
-12
View File
@@ -16,18 +16,6 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
---
## Provider Snapshot Refresh Contract
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
--- ---
## ACP Provider Checklist ## ACP Provider Checklist
+11 -85
View File
@@ -21,11 +21,11 @@ Before running any stable patch release command:
npm run release:patch npm run release:patch
``` ```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo. This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below). If asked to "release paseo" without specifying major/minor, treat it as a patch release.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta". Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step ## Manual step-by-step
@@ -61,7 +61,7 @@ Use the beta path when you need to:
## Staged rollout (stable channel) ## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately. Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`. The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
@@ -74,16 +74,16 @@ Updater clients only discover a release through those `.yml` manifests, so there
### Default behavior ### Default behavior
`npm run release:patch` → tag push → 36h ramp. No extra action needed. `npm run release:patch` → tag push → 24h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below. The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish) ### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after: For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash ```bash
# 1. Cut and publish (default 36h ramp from tag push). # 1. Cut and publish (default 24h ramp from tag push).
npm run release:patch npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes. # 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
@@ -92,7 +92,7 @@ gh workflow run desktop-rollout.yml \
-f rollout_hours=0 -f rollout_hours=0
``` ```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check. **Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish. Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
@@ -132,7 +132,7 @@ gh workflow run desktop-release.yml \
-f rollout_hours=6 -f rollout_hours=6
``` ```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`). This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout ### Releasing during an active rollout
@@ -147,63 +147,6 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. - **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. - **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
## Mobile builds (EAS)
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
```bash
cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Filter by platform.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
```
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`).
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
### Babysitting mobile after a release
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
Pattern:
```jsonc
// mcp__paseo__create_schedule arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"every": "15m",
"maxRuns": 8, // covers ~2h of build + store-submission window
"target": "self", // heartbeat, NOT "new-agent"
"cwd": "/path/to/paseo",
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build that errors at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as everything is green so the remaining runs do not generate noise.
## Release notes on GitHub ## Release notes on GitHub
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input. The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
@@ -287,20 +230,7 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers. 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. - **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`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy. - **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
| Wrong (implementation-facing) | Right (user-facing) |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
| Added debouncing to the search input | Search results no longer lag behind typing |
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **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. - **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. - **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.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet. - **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
@@ -311,12 +241,9 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list. Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long. - **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change. - **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it. - **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…". - **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets. - **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
@@ -392,5 +319,4 @@ In other words, betas are checkpoints along the way; the changelog entry remains
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully - [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane - [ ] EAS `release-mobile.yml` workflow for the same tag is green
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store
-84
View File
@@ -1,84 +0,0 @@
# RPC Namespacing
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Operation: `set_auto_merge`
- Direction: `request` or `response`
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
## Request/Response Pairs
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
## Message Shape
Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
requestId: "req_123"
}
```
Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
success: true,
error: null,
requestId: "req_123"
}
}
```
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
Provider-specific behavior belongs under the provider segment:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
## Compatibility
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
```ts
checkout_pr_merge_request;
checkout_pr_merge_response;
```
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
- Add the new names first.
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
- Keep old names accepted until the compatibility window expires.
- Mark shims with `COMPAT(...)` and a removal date.
-7
View File
@@ -26,18 +26,11 @@
let let
pkgs = pkgsFor system; pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { }; paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in in
{ {
default = paseo; default = paseo;
paseo = paseo; paseo = paseo;
} }
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
}
); );
nixosModules.default = self.nixosModules.paseo; nixosModules.default = self.nixosModules.paseo;
-2
View File
@@ -3,8 +3,6 @@ pre-commit:
jobs: jobs:
- name: format - name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}" glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
exclude:
- "**/package-lock.json"
run: npm run format:check:files -- {staged_files} run: npm run format:check:files -- {staged_files}
- name: lint - name: lint
glob: "*.{js,jsx,ts,tsx}" glob: "*.{js,jsx,ts,tsx}"
-159
View File
@@ -1,159 +0,0 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
# without this, the desktop drv produces a separately-named store path
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
paseo,
}:
buildNpmPackage rec {
pname = "paseo-desktop";
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 mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store"
&& baseName != "release";
};
nodejs = nodejs_22;
inherit (paseo) npmDeps;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
EXPO_NO_TELEMETRY = "1";
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
};
buildPhase = ''
runHook preBuild
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# App workspace deps not covered by build:daemon
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only — NOT electron-builder)
npm run build:main --workspace=@getpaseo/desktop
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
copyDesktopItems
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
})
];
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
};
}
+16 -121
View File
@@ -81,48 +81,7 @@ in
enable = lib.mkOption { enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = true;
description = '' description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented — the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
}; };
}; };
@@ -135,9 +94,8 @@ in
When Paseo runs as a real user (not the default system user), AI agents 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 need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile, home-manager profile (`~/.nix-profile/bin` and NixOS profile and system paths so agents can use them without manually
`~/.local/state/nix/profile/bin`), and system paths so agents can use setting PATH.
them without manually setting PATH.
Enabled by default when `user` is set to a non-default value. Enabled by default when `user` is set to a non-default value.
''; '';
@@ -153,50 +111,9 @@ in
''; '';
description = "Extra environment variables for the Paseo daemon."; description = "Extra environment variables for the Paseo daemon.";
}; };
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI — not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable {
let
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
in
{
assertions = [
{
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
message = ''
services.paseo.relay.host must be set when relay.mode = "remote".
'';
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") { users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true; isSystemUser = true;
group = cfg.group; group = cfg.group;
@@ -214,45 +131,24 @@ in
after = [ "network.target" ]; after = [ "network.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = { environment = {
NODE_ENV = "production"; NODE_ENV = "production";
PASEO_HOME = cfg.dataDir; PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}"; PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment ( } // lib.optionalAttrs cfg.inheritUserEnvironment {
let # mkForce overrides the default PATH from NixOS's systemd module (which
# Match dataDir's convention. We can't read users.users.<name>.home # only includes store paths for coreutils/grep/sed/systemd). Our PATH
# because the user may be managed outside NixOS. # includes /run/current-system/sw/bin which is a superset of those.
userHome = "/home/${cfg.user}"; PATH = lib.mkForce (lib.concatStringsSep ":" [
in { "/etc/profiles/per-user/${cfg.user}/bin"
# mkForce overrides the default PATH from NixOS's systemd module (which "/run/current-system/sw/bin"
# only includes store paths for coreutils/grep/sed/systemd). When the "/run/wrappers/bin"
# daemon runs as a real user, also include home-manager profile paths "/nix/var/nix/profiles/default/bin"
# so user-installed CLIs (claude, opencode, codex, ...) are reachable ]);
# by agent processes the daemon spawns. } // lib.optionalAttrs (cfg.hostnames == true) {
PATH = lib.mkForce (lib.concatStringsSep ":" (
lib.optionals (cfg.user != "paseo") [
"${userHome}/.nix-profile/bin"
"${userHome}/.local/state/nix/profile/bin"
]
++ [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]
));
}
) // lib.optionalAttrs (cfg.hostnames == true) {
PASEO_HOSTNAMES = "true"; PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) { } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames; PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // cfg.environment; } // cfg.environment;
serviceConfig = { serviceConfig = {
@@ -276,6 +172,5 @@ in
environment.systemPackages = [ cfg.package ]; environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
} };
);
} }
-1
View File
@@ -1 +0,0 @@
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=
+40 -27
View File
@@ -7,15 +7,6 @@
makeWrapper, makeWrapper,
# node-pty needs libuv headers on Linux # node-pty needs libuv headers on Linux
libuv, libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}: }:
buildNpmPackage rec { buildNpmPackage rec {
@@ -49,9 +40,9 @@ buildNpmPackage rec {
nodejs = nodejs_22; nodejs = nodejs_22;
# Default hash lives in nix/npm-deps.hash (see arg default above). # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates that file when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash; npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# Prevent onnxruntime-node's install script from running during automatic # 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). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -88,25 +79,47 @@ buildNpmPackage rec {
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
# Compute the daemon's runtime closure by static module-graph tracing
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
# forked terminal-worker-process.js) plus an explicit list of non-JS
# assets read at runtime. The trace script is the single source of
# truth for what the daemon needs at $out — auditable in plain JS, no
# npm hoisting / .bin / workspace-symlink footguns.
mkdir -p $out/lib/paseo mkdir -p $out/lib/paseo
node scripts/trace-daemon.mjs > daemon-files.txt
while IFS= read -r path; do # Copy root package metadata
[ -z "$path" ] && continue
mkdir -p "$out/lib/paseo/$(dirname "$path")"
cp -a "$path" "$out/lib/paseo/$path"
done < daemon-files.txt
# Root package.json lets node resolve the workspace layout when the
# CLI/server bin starts from $out.
cp package.json $out/lib/paseo/ 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) # Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \ makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
+259 -705
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.78", "version": "0.1.71",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [ "keywords": [
@@ -93,7 +93,6 @@
"devDependencies": { "devDependencies": {
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1", "@typescript/native-preview": "7.0.0-dev.20260423.1",
"@vercel/nft": "^1.5.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"get-port-cli": "^3.0.0", "get-port-cli": "^3.0.0",
@@ -41,7 +41,7 @@ jobs:
submit_ios_for_review: submit_ios_for_review:
name: Submit iOS for App Store review name: Submit iOS for App Store review
needs: [build_ios, submit_ios] needs: [submit_ios]
environment: production environment: production
runs_on: macos-medium runs_on: macos-medium
steps: steps:
@@ -49,7 +49,4 @@ jobs:
- name: Install fastlane - name: Install fastlane
run: bundle install run: bundle install
- name: Submit for review - name: Submit for review
run: | run: bundle exec fastlane ios submit_review
export APP_VERSION="${{ needs.build_ios.outputs.app_version }}"
export APP_BUILD_VERSION="${{ needs.build_ios.outputs.app_build_version }}"
bundle exec fastlane ios submit_review
@@ -1,21 +1,12 @@
name: Resubmit iOS for App Store review name: Resubmit iOS for App Store review
# Standalone re-trigger for the App Store review submission step. The iOS # Standalone re-trigger for the App Store review submission step. The iOS
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push # binary is already uploaded to TestFlight via the main release-mobile flow;
# build; this workflow just runs the fastlane submit_review lane against the # this workflow just runs the fastlane submit_review lane against the latest
# latest TestFlight build, without rebuilding or re-uploading. # TestFlight build, without rebuilding or re-uploading.
on: on:
workflow_dispatch: workflow_dispatch: {}
inputs:
app_version:
type: string
required: false
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
app_build_version:
type: string
required: false
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
jobs: jobs:
submit_ios_for_review: submit_ios_for_review:
@@ -27,7 +18,4 @@ jobs:
- name: Install fastlane - name: Install fastlane
run: bundle install run: bundle install
- name: Submit for review - name: Submit for review
run: | run: bundle exec fastlane ios submit_review
export APP_VERSION="${{ inputs.app_version }}"
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
bundle exec fastlane ios submit_review
+1 -1
View File
@@ -1,3 +1,3 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "fastlane", "~> 2.234" gem "fastlane"
-20
View File
@@ -6,7 +6,6 @@ import {
expectTurnCopyButton, expectTurnCopyButton,
expectScrollFollowsNewContent, expectScrollFollowsNewContent,
} from "./helpers/agent-stream"; } from "./helpers/agent-stream";
import { clickNewChat } from "./helpers/launcher";
import { startRunningMockAgent } from "./helpers/composer"; import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => { test.describe("Agent stream UI", () => {
@@ -43,23 +42,4 @@ test.describe("Agent stream UI", () => {
await repo.cleanup(); await repo.cleanup();
} }
}); });
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
await workspace.navigateTo();
await clickNewChat(page);
await page.getByText("Model defaults are still loading").waitFor({
state: "hidden",
timeout: 30_000,
});
const prompt = "Stream briefly for first app-created turn timer test.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await page.getByText(prompt, { exact: true }).first().waitFor({ state: "visible" });
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
});
}); });
@@ -1,205 +0,0 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
expectSessionRowArchived,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openSessions,
} from "./helpers/archive-tab";
interface SlashCommandScenario {
agent: { id: string };
client: TerminalPerfDaemonClient;
cwd: string;
title: string;
}
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function withOpenReadyMockAgent(
page: Page,
input: {
title: string;
model?: string;
modeId?: string;
},
run: (scenario: SlashCommandScenario) => Promise<void>,
): Promise<void> {
const repo = await createTempGitRepo("client-slash-command-");
const client = await connectTerminalClient();
try {
await openProject(client, repo.path);
const agent = await createReadyMockAgent(client, {
cwd: repo.path,
title: input.title,
model: input.model,
modeId: input.modeId,
});
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
await run({ agent, client, cwd: repo.path, title: input.title });
} finally {
await client.close();
await repo.cleanup();
}
}
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
const opened = await client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
}
}
async function createReadyMockAgent(
client: TerminalPerfDaemonClient,
input: {
cwd: string;
title: string;
model?: string;
modeId?: string;
},
): Promise<{ id: string }> {
const agent = await client.createAgent({
provider: "mock",
cwd: input.cwd,
title: input.title,
modeId: input.modeId ?? "load-test",
model: input.model ?? "ten-second-stream",
initialPrompt: "Prepare a client slash command test agent.",
});
return { id: agent.id };
}
async function openActiveAgentTab(
page: Page,
input: { cwd: string; agentId: string },
): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectWorkspaceTabVisible(page, input.agentId);
await expectComposerVisible(page);
}
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(command);
await expect(input).toHaveValue(command);
await input.press("Enter");
}
async function selectClientSlashCommand(page: Page, query: string, label: string): Promise<void> {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(query);
await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
await input.press("Enter");
}
async function expectAgentArchivedInSessions(page: Page, title: string): Promise<void> {
await openSessions(page);
await expectSessionRowArchived(page, title);
}
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
await expectComposerVisible(page);
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
await submitMessage(page, REPLACEMENT_PROMPT);
}
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
let newAgentId: string | null = null;
await expect
.poll(
async () => {
const ids = await page
.locator('[data-testid^="workspace-tab-agent_"]')
.evaluateAll((nodes) =>
nodes.flatMap((node) => {
if (!(node instanceof HTMLElement)) {
return [];
}
const testId = node.getAttribute("data-testid") ?? "";
if (!testId.startsWith("workspace-tab-agent_")) {
return [];
}
if (node.offsetParent === null) {
return [];
}
return [testId.slice("workspace-tab-agent_".length)];
}),
);
newAgentId = ids.find((id) => id !== oldAgentId) ?? null;
return newAgentId;
},
{ timeout: 30_000 },
)
.not.toBeNull();
if (!newAgentId) {
throw new Error("Replacement agent was not created.");
}
return newAgentId;
}
test.describe("Client slash commands", () => {
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
await runClientSlashCommand(page, "/quit");
await expectWorkspaceTabHidden(page, agent.id);
await expectAgentArchivedInSessions(page, title);
});
});
test("slash quit selected from autocomplete archives immediately", async ({ page }) => {
await withOpenReadyMockAgent(
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
await selectClientSlashCommand(page, "/qu", "/quit");
await expectWorkspaceTabHidden(page, agent.id);
await expectAgentArchivedInSessions(page, title);
},
);
});
test("slash clear replaces the active agent with a matching draft", async ({ page }) => {
await withOpenReadyMockAgent(
page,
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
async ({ agent, title }) => {
await runClientSlashCommand(page, "/clear");
await expectWorkspaceTabHidden(page, agent.id);
await expectReplacementDraftMatchesPreviousSetup(page);
await createAgentFromReplacementDraft(page);
await waitForReplacementAgentId(page, agent.id);
await expectAgentArchivedInSessions(page, title);
},
);
});
});
+3 -4
View File
@@ -21,13 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
} }
export async function openMobileAgentSidebar(page: Page): Promise<void> { export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click(); await page.getByTestId("menu-button").click();
} }
// force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> { export async function closeMobileAgentSidebar(page: Page): Promise<void> {
const closeButton = page.getByTestId("sidebar-close"); await page.getByTestId("menu-button").click({ force: true });
await expect(closeButton).toBeInViewport({ timeout: 5_000 });
await closeButton.click({ force: true });
} }
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. // The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
@@ -29,11 +29,6 @@ export interface TerminalPerfDaemonClient {
featureValues?: Record<string, unknown>; featureValues?: Record<string, unknown>;
initialPrompt?: string; initialPrompt?: string;
}): Promise<{ id: string; status: string }>; }): Promise<{ id: string; status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>; sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal( subscribeTerminal(
terminalId: string, terminalId: string,
@@ -1,64 +0,0 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
} from "./helpers/archive-tab";
import { expectComposerVisible } from "./helpers/composer";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
test.describe("Workspace pane mounting", () => {
test("opening the first split pane keeps the existing agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("pane-remount-");
let agentId: string | null = null;
try {
const agent = await createIdleAgent(client, {
cwd: repo.path,
title: `pane-remount-${Date.now()}`,
});
agentId = agent.id;
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
const originalComposer = await page
.getByTestId("message-input-root")
.filter({ visible: true })
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
await page.getByRole("button", { name: "Split pane right" }).first().click();
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
2,
{ timeout: 30_000 },
);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
} finally {
if (agentId) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});
+12 -35
View File
@@ -11,6 +11,11 @@ platform :ios do
key_filepath: ENV.fetch("ASC_KEY_P8"), key_filepath: ENV.fetch("ASC_KEY_P8"),
) )
build_number = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
)
require "spaceship" require "spaceship"
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create( Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
key_id: ENV.fetch("ASC_KEY_ID"), key_id: ENV.fetch("ASC_KEY_ID"),
@@ -20,58 +25,30 @@ platform :ios do
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER) app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil? UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
# The release workflow forwards the marketing version and CFBundleVersion of
# the binary it just built. Use them to identify the exact build, since
# CFBundleVersion is not unique across marketing versions in this project
# (the production profile resets it per app_version). The resubmit workflow
# leaves these unset and falls back to "most recently uploaded iOS build".
target_version = ENV["APP_VERSION"].to_s.strip
target_build = ENV["APP_BUILD_VERSION"].to_s.strip
target_version = nil if target_version.empty?
target_build = nil if target_build.empty?
if target_version
UI.message("Targeting App Store version #{target_version}#{target_build ? " build #{target_build}" : ""} from workflow input")
else
UI.message("No APP_VERSION provided; selecting the most recently uploaded iOS build")
end
valid_build = nil valid_build = nil
deadline = Time.now + (30 * 60) deadline = Time.now + (30 * 60)
loop do loop do
builds = Spaceship::ConnectAPI::Build.all( builds = Spaceship::ConnectAPI::Build.all(
app_id: app.id, app_id: app.id,
sort: "-uploadedDate", build_number: build_number.to_s,
limit: 100, )
).select { |b| b.pre_release_version&.platform == "IOS" } build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
builds = builds.select { |b| b.pre_release_version&.version == target_version } if target_version state = build&.processing_state
builds = builds.select { |b| b.version == target_build } if target_build UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
build = builds.first
if build.nil?
UI.user_error!("Timed out waiting for build to appear on App Store Connect.") if Time.now > deadline
UI.message("No matching iOS build visible on App Store Connect yet; waiting...")
sleep(30)
next
end
state = build.processing_state
UI.message("Build #{build.pre_release_version.version} (#{build.version}) processing state: #{state || 'unknown'}")
if state == "VALID" if state == "VALID"
valid_build = build valid_build = build
break break
end end
if state == "INVALID" || state == "FAILED" if state == "INVALID" || state == "FAILED"
UI.user_error!("Build #{build.version} failed App Store processing (state: #{state}).") UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
end end
if Time.now > deadline if Time.now > deadline
UI.user_error!("Timed out waiting for build #{build.version} to finish processing on App Store Connect.") UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
end end
sleep(30) sleep(30)
end end
app_version = valid_build.pre_release_version.version app_version = valid_build.pre_release_version.version
build_number = valid_build.version
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review") UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
# Ensure an editable App Store version exists for this marketing version. # Ensure an editable App Store version exists for this marketing version.
@@ -1,7 +0,0 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["PaseoHardwareKeyboardModule"],
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
}
}
@@ -1,24 +0,0 @@
require 'json'
Pod::Spec.new do |s|
s.name = 'PaseoHardwareKeyboard'
s.version = '0.1.0'
s.summary = 'Hardware keyboard shortcuts for Paseo'
s.description = 'Hardware keyboard shortcuts for Paseo'
s.license = 'AGPL-3.0-or-later'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '13.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
end
@@ -1,99 +0,0 @@
import ExpoModulesCore
import UIKit
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
@objc
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
public override func createRootViewController() -> UIViewController? {
return PaseoHardwareKeyboardRootViewController()
}
}
public class PaseoHardwareKeyboardModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoHardwareKeyboard")
Events(hardwareSubmitEventName)
OnCreate {
activeModule = self
}
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
DispatchQueue.main.async {
isHardwareSubmitEnabled = enabled
}
}
OnDestroy {
if activeModule === self {
activeModule = nil
}
isHardwareSubmitEnabled = false
}
}
fileprivate func emitHardwareKeyboardSubmit() {
sendEvent(hardwareSubmitEventName, [:])
}
}
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
override var keyCommands: [UIKeyCommand]? {
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
return super.keyCommands
}
let command = UIKeyCommand(
input: "\r",
modifierFlags: [],
action: #selector(handleHardwareKeyboardSubmit(_:))
)
if #available(iOS 15.0, *) {
command.wantsPriorityOverSystemBehavior = true
}
return (super.keyCommands ?? []) + [command]
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
return
}
activeModule?.emitHardwareKeyboardSubmit()
}
private func canSubmitCurrentTextInput() -> Bool {
guard let responder = UIResponder.paseoCurrentFirstResponder else {
return false
}
guard let textInput = responder as? UITextInput else {
return false
}
return textInput.markedTextRange == nil
}
}
private extension UIResponder {
private static weak var currentFirstResponder: UIResponder?
static var paseoCurrentFirstResponder: UIResponder? {
currentFirstResponder = nil
UIApplication.shared.sendAction(
#selector(captureCurrentFirstResponder(_:)),
to: nil,
from: nil,
for: nil
)
return currentFirstResponder
}
@objc
private func captureCurrentFirstResponder(_ sender: Any?) {
UIResponder.currentFirstResponder = self
}
}
@@ -1,5 +0,0 @@
{
"name": "paseo-hardware-keyboard",
"version": "0.1.0",
"private": true
}
+5 -6
View File
@@ -1,16 +1,16 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.78", "version": "0.1.71",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"start": "cross-env APP_VARIANT=development expo start", "start": "APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio", "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps", "eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development", "android": "npm run android:development",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", "android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", "android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production", "android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"", "android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "expo run:ios", "ios": "expo run:ios",
@@ -33,7 +33,7 @@
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "*", "@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*", "@getpaseo/highlight": "*",
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
@@ -63,7 +63,6 @@
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8", "expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7", "expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
+2 -27
View File
@@ -56,7 +56,6 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -376,8 +375,6 @@ function QueryProvider({ children }: { children: ReactNode }) {
const rowStyle = { flex: 1, flexDirection: "row" } as const; const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const; const flexStyle = { flex: 1 } as const;
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
interface AppContainerProps { interface AppContainerProps {
children: ReactNode; children: ReactNode;
@@ -409,7 +406,6 @@ function AppContainer({
}, [settings.theme, updateSettings]); }, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname(); const pathname = usePathname();
const activeServerId = useMemo( const activeServerId = useMemo(
@@ -502,7 +498,6 @@ function MobileGestureWrapper({
openGestureRef, openGestureRef,
} = useSidebarAnimation(); } = useSidebarAnimation();
const touchStartX = useSharedValue(0); const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent"; const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => { const handleGestureOpen = useCallback(() => {
@@ -521,7 +516,6 @@ function MobileGestureWrapper({
const touch = event.changedTouches[0]; const touch = event.changedTouches[0];
if (touch) { if (touch) {
touchStartX.value = touch.absoluteX; touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
} }
}) })
.onTouchesMove((event, stateManager) => { .onTouchesMove((event, stateManager) => {
@@ -529,31 +523,13 @@ function MobileGestureWrapper({
if (!touch || event.numberOfTouches !== 1) return; if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value; const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (horizontalScroll?.isAnyScrolledRight.value) { if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail(); stateManager.fail();
return; return;
} }
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) { if (deltaX > 15) {
stateManager.fail();
return;
}
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX > 15 && absDeltaX > absDeltaY) {
stateManager.activate(); stateManager.activate();
} }
}) })
@@ -595,12 +571,11 @@ function MobileGestureWrapper({
openGestureRef, openGestureRef,
horizontalScroll?.isAnyScrolledRight, horizontalScroll?.isAnyScrolledRight,
touchStartX, touchStartX,
touchStartY,
], ],
); );
return ( return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}> <GestureDetector gesture={openGesture} touchAction="pan-y">
{children} {children}
</GestureDetector> </GestureDetector>
); );
+1 -8
View File
@@ -2,16 +2,10 @@ import { useLocalSearchParams } from "expo-router";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() { export default function HostNewWorkspaceRoute() {
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
serverId?: string;
dir?: string;
name?: string;
projectId?: string;
}>();
const serverId = typeof params.serverId === "string" ? params.serverId : ""; const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : ""; const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const displayName = typeof params.name === "string" ? params.name : undefined; const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
if (!sourceDirectory) { if (!sourceDirectory) {
return null; return null;
@@ -22,7 +16,6 @@ export default function HostNewWorkspaceRoute() {
serverId={serverId} serverId={serverId}
sourceDirectory={sourceDirectory} sourceDirectory={sourceDirectory}
displayName={displayName} displayName={displayName}
projectId={projectId}
/> />
); );
} }
@@ -1,13 +0,0 @@
export {
AssistantInlineCodePathLink,
AssistantInlinePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkResolver } from "./use-resolver";
@@ -1,346 +0,0 @@
import {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type MouseEvent,
} from "react";
import {
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
import type { AssistantFileLinkSource } from "./resolver";
interface AssistantInlinePathLinkProps {
content: string;
parsed: InlinePathTarget;
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string;
style: StyleProp<TextStyle>;
}
export function AssistantInlinePathLink({
content,
parsed,
onPress,
workspaceRoot,
style,
}: AssistantInlinePathLinkProps) {
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(parsed, "side");
},
[onPress, parsed],
);
if (!isNative) {
return (
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
<a
href={parsed.path}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
</a>
</FileLinkHoverTooltip>
);
}
return (
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
);
}
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
export function AssistantMarkdownLink({
source,
style,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const href = source.href;
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(source, "side");
},
[onPress, source],
);
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
const handleHoverIn = useCallback(() => {
setHovered(true);
handlePrefetch();
}, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const tooltipFilePath = useMemo(
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
[source.href, workspaceRoot],
);
if (isNative) {
return (
<Text accessibilityRole="link" onPress={handlePress} style={style}>
{children}
</Text>
);
}
const anchor = (
<a
href={href}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
onPress={handlePress}
onFocus={handlePrefetch}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text style={hoveredTextStyle}>{children}</Text>
</Pressable>
</a>
);
if (tooltipFilePath) {
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
}
return anchor;
}
interface AssistantMarkdownCodeLinkProps {
source: AssistantFileLinkSource;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
export function AssistantMarkdownCodeLink({
source,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
() => [inheritedStyles, codeInlineStyle, linkStyle],
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink
source={source}
style={style}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{children}
</AssistantMarkdownLink>
);
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{content}
</AssistantMarkdownCodeLink>
);
}
function getMarkdownLinkTooltipFilePath(
href: string,
workspaceRoot: string | undefined,
): string | null {
const classification = classifyAssistantFileLink(href, { workspaceRoot });
if (classification?.kind !== "directFile") {
return null;
}
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
}
function formatInlinePathTargetForTooltip(
target: InlinePathTarget,
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
if (!workspaceRoot) {
return filePath;
}
const root = workspaceRoot.replace(/\/+$/, "");
if (!root) {
return filePath;
}
if (filePath === root) {
return ".";
}
const prefix = `${root}/`;
if (filePath.startsWith(prefix)) {
return filePath.slice(prefix.length);
}
return filePath;
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow.
display: "inline-flex" as ViewStyle["display"],
};
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
if (!isWeb) {
return children;
}
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger>
<TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
</Text>
</View>
</View>
</TooltipContent>
</Tooltip>
);
}
const LINK_ANCHOR_STYLE: CSSProperties = {
display: "contents",
color: "inherit",
textDecoration: "none",
};
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
event.preventDefault();
}
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
return event.metaKey || event.ctrlKey;
}
const styles = StyleSheet.create((theme) => ({
tooltipBody: {
gap: theme.spacing[1],
},
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
tooltipHintRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
tooltipHintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -1,604 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistantFileLinkResolver,
getAssistantFileLinkToken,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
} from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
};
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
interface DirectorySearch {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
class FakeWorkspaceFiles {
readonly searches: DirectorySearch[] = [];
readonly openedFiles: OpenedFile[] = [];
readonly unresolvedTokens: string[] = [];
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
});
}
private getDirectorySuggestions = async (
search: DirectorySearch,
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
private openWorkspaceFile = (
target: InlinePathTarget,
disposition: OpenFileDisposition,
): void => {
this.openedFiles.push({ target, disposition });
};
private onUnresolvedFileCandidate = (token: string): void => {
this.unresolvedTokens.push(token);
};
}
describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
);
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await Promise.all([
resolver.prefetch({ context: CONTEXT, source }),
resolver.prefetch({ context: CONTEXT, source }),
]);
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(result.opened).toBe(true);
});
it("click consumes an in-flight hover resolution", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const suggestions = vi.fn(() => deferred.promise);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
const prefetch = resolver.prefetch({ context: CONTEXT, source });
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("retries a click after hover prefetch fails to query suggestions", async () => {
const suggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(openResult.opened).toBe(true);
});
it("does not cache unresolved candidates", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(first).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
expect(second.opened).toBe(true);
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
});
it("keys cache entries by server, workspace, and token", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await resolver.open({ context: CONTEXT, source, disposition: "main" });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
disposition: "main",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
{
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
});
it("does not apply stale async results after the active context changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
let isCurrent = true;
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(() => deferred.promise),
openWorkspaceFile,
openExternalUrl: vi.fn(),
isCurrentContext: () => isCurrent,
});
const opened = resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
isCurrent = false;
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
const result = await opened;
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(result.opened).toBe(false);
expect(result.kind).toBe("file");
});
it("opens direct workspace file links without querying suggestions", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("preserves direct workspace file line ranges", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx:33-40" },
disposition: "main",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
"main",
);
});
it("opens basename line refs when the daemon returns that exact filename", async () => {
const workspaceFiles = new FakeWorkspaceFiles({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "file.ts:12",
text: "file.ts:12",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([
{
target: {
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
lineEnd: undefined,
},
disposition: "main",
},
]);
expect(result.opened).toBe(true);
});
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
const workspaceFiles = new FakeWorkspaceFiles({});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "src/file.ts",
text: "src/file.ts",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "src/file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([]);
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "src/file.ts",
opened: false,
});
});
it("passes side open disposition to workspace file links", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "side",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"side",
);
});
it("keeps explicit external URLs external", async () => {
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md" },
disposition: "main",
});
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
expect(result).toEqual({
kind: "external",
url: "http://dumm.md",
opened: true,
});
});
it("keeps auto-linkified normal domains external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
expect(result).toEqual({
kind: "external",
url: "http://google.com",
opened: true,
});
});
it("keeps auto-linkified normal domain paths external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
expect(result).toEqual({
kind: "external",
url: "http://openai.com/path",
opened: true,
});
});
it("does not open unresolved linkified markdown filenames in the browser", async () => {
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const prefetchResult = await resolver.prefetch({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
disposition: "main",
});
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(openExternalUrl).not.toHaveBeenCalled();
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("keeps failed ambiguous resolution out of the browser", async () => {
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => {
throw new Error("daemon unavailable");
}),
openWorkspaceFile: vi.fn(),
openExternalUrl,
onUnresolvedFileCandidate,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
getAssistantFileLinkToken({
href: "http://dumm.md",
text: "dumm.md",
markup: "linkify",
sourceInfo: "auto",
}),
).toBe("dumm.md");
expect(
getAssistantFileLinkToken({
href: "http://google.com",
text: "google.com",
markup: "linkify",
sourceInfo: "auto",
}),
).toBe("http://google.com");
expect(
getAssistantFileLinkToken({
href: "http://dumm.md",
text: "dumm.md",
markup: "",
sourceInfo: "",
}),
).toBe("http://dumm.md");
expect(
getAssistantFileLinkToken({
href: "workspace-git-service.ts:1553",
text: "workspace-git-service.ts:1553",
sourceType: "inline-code",
}),
).toBe("workspace-git-service.ts:1553");
});
});
@@ -1,307 +0,0 @@
import {
classifyAssistantFileLink,
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource {
href: string;
text?: string;
markup?: string;
sourceInfo?: string;
sourceType?: "inline-code";
}
export interface DirectorySuggestionEntry {
path: string;
kind: "file" | "directory";
}
export interface DirectorySuggestionResult {
entries: DirectorySuggestionEntry[];
error: string | null;
}
export interface AssistantFileLinkResolverDependencies {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkPrefetchInput {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
disposition: OpenFileDisposition;
}
export type ResolvedAssistantFileLink =
| {
kind: "external";
url: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
token: string;
}
| {
kind: "ignored";
};
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
opened: boolean;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
token: string;
classification: AssistantFileLinkClassification;
}
export function createAssistantFileLinkResolver(
dependencies: AssistantFileLinkResolverDependencies,
): AssistantFileLinkResolver {
const cache = new Map<string, CachedAssistantFileLink>();
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
async function resolve(
input: AssistantFileLinkPrefetchInput,
): Promise<ResolvedAssistantFileLink> {
const parsed = parseInteraction(input);
if (!parsed) {
return { kind: "ignored" };
}
if (parsed.classification.kind === "external") {
return { kind: "external", url: parsed.classification.raw };
}
if (
parsed.classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context: input.context,
source: input.source,
token: parsed.token,
target: parsed.classification.target,
})
) {
return { kind: "file", target: parsed.classification.target };
}
const key = getResolutionKey(input.context, parsed.token);
const cached = cache.get(key);
if (cached) {
return cached;
}
const active = inFlight.get(key);
if (active) {
return active;
}
const request = resolveAmbiguousCandidate({
context: input.context,
token: parsed.token,
target: parsed.classification.target,
getDirectorySuggestions: dependencies.getDirectorySuggestions,
})
.then((result) => {
if (result.kind === "file") {
cache.set(key, result);
}
inFlight.delete(key);
return result;
})
.catch((): CachedAssistantFileLink => {
inFlight.delete(key);
return { kind: "unresolvedFileCandidate", token: parsed.token };
});
inFlight.set(key, request);
return request;
}
return {
prefetch(input) {
return resolve(input);
},
async open(input) {
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
if (resolved.kind === "file") {
dependencies.openWorkspaceFile(resolved.target, input.disposition);
return { ...resolved, opened: true };
}
if (resolved.kind === "external") {
await dependencies.openExternalUrl(resolved.url);
return { ...resolved, opened: true };
}
if (resolved.kind === "unresolvedFileCandidate") {
dependencies.onUnresolvedFileCandidate?.(resolved.token);
}
return { ...resolved, opened: false };
},
};
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return text;
}
}
return source.href;
}
function parseInteraction(
input: AssistantFileLinkPrefetchInput,
): ParsedAssistantFileLinkInteraction | null {
const token = getAssistantFileLinkToken(input.source).trim();
if (!token) {
return null;
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: input.context.workspaceRoot,
});
if (!classification) {
return null;
}
return { token, classification };
}
async function resolveAmbiguousCandidate(input: {
context: AssistantFileLinkContext;
token: string;
target: InlinePathTarget;
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
}): Promise<CachedAssistantFileLink> {
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
const suggestions = await input.getDirectorySuggestions({
query,
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
return {
kind: "file",
target: {
...input.target,
path: joinWorkspacePath(workspaceRoot, match.path),
},
};
}
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
const lastSlash = normalizedPath.lastIndexOf("/");
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
}
function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
token: string;
target: InlinePathTarget;
}): boolean {
if (input.source.sourceType !== "inline-code") {
return false;
}
if (isAbsoluteInlineCodeToken(input.token)) {
return false;
}
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return false;
}
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = input.target.path.replace(/\\/g, "/");
return normalizedPath.startsWith(`${normalizedRoot}/`);
}
function isAbsoluteInlineCodeToken(token: string): boolean {
return (
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
}
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
return source.markup === "linkify" || source.sourceInfo === "auto";
}
function canApplyResult(
context: AssistantFileLinkContext,
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
): boolean {
return isCurrentContext ? isCurrentContext(context) : true;
}
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return root ? `${root}/${child}` : child;
}
@@ -1,90 +0,0 @@
import { useMemo, useRef } from "react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ToastApi } from "@/components/toast-host";
import {
createAssistantFileLinkResolver,
type AssistantFileLinkContext,
type AssistantFileLinkOpenInput,
type AssistantFileLinkPrefetchInput,
} from "./resolver";
import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {
client?: DaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkActions {
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
}
export function useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
const context: AssistantFileLinkContext = useMemo(
() => ({
serverId,
workspaceRoot,
}),
[serverId, workspaceRoot],
);
const latestContextRef = useRef(context);
latestContextRef.current = context;
const resolver = useMemo(
() =>
createAssistantFileLinkResolver({
async getDirectorySuggestions(input) {
if (!client) {
return { entries: [], error: null };
}
const result = await client.getDirectorySuggestions(input);
return {
entries: result.entries,
error: result.error,
};
},
openWorkspaceFile(target, disposition) {
onOpenWorkspaceFile?.(target, disposition);
},
openExternalUrl,
onUnresolvedFileCandidate(token) {
toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
},
isCurrentContext(candidate) {
const current = latestContextRef.current;
return (
current.serverId === candidate.serverId &&
current.workspaceRoot === candidate.workspaceRoot
);
},
}),
[client, onOpenWorkspaceFile, toast],
);
return useMemo(
() => ({
prefetch(input) {
void resolver.prefetch({ ...input, context });
},
open(input) {
void resolver.open({ ...input, context });
},
}),
[context, resolver],
);
}
@@ -1,130 +0,0 @@
import { describe, expect, it } from "vitest";
import {
CLIENT_SLASH_COMMANDS,
buildDraftAgentSetup,
resolveClientSlashCommand,
} from "@/client-slash-commands";
import type { Agent } from "@/stores/session-store";
function createAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date("2026-05-15T00:00:00.000Z");
return {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "idle",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: "mode-current",
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: "codex",
sessionId: "session-1",
model: "runtime-model",
modeId: "runtime-mode",
thinkingOptionId: "runtime-thinking",
},
title: "Agent",
cwd: "/repo",
model: "agent-model",
thinkingOptionId: "think-hard",
features: [
{ type: "toggle", id: "web-search", label: "Web search", value: true },
{
type: "select",
id: "effort",
label: "Effort",
value: "high",
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null,
labels: {},
...overrides,
};
}
describe("resolveClientSlashCommand", () => {
it("declares the exact client commands that execute immediately", () => {
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
["quit", "immediate"],
["exit", "immediate"],
["q", "immediate"],
["clear", "immediate"],
["new", "immediate"],
]);
});
it("resolves exact client commands after trimming", () => {
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
kind: "archive-agent",
execution: "immediate",
});
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
});
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {
expect(resolveClientSlashCommand({ text: "/clear now", hasAttachments: false })).toBeNull();
expect(resolveClientSlashCommand({ text: "/quit now", hasAttachments: false })).toBeNull();
expect(
resolveClientSlashCommand({ text: "/provider-command", hasAttachments: false }),
).toBeNull();
expect(resolveClientSlashCommand({ text: "hello /quit", hasAttachments: false })).toBeNull();
expect(resolveClientSlashCommand({ text: "/quit", hasAttachments: true })).toBeNull();
});
});
describe("buildDraftAgentSetup", () => {
it("builds draft setup from the active agent snapshot", () => {
expect(buildDraftAgentSetup(createAgent())).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "mode-current",
model: "agent-model",
thinkingOptionId: "think-hard",
featureValues: {
"web-search": true,
effort: "high",
},
});
});
it("falls back to runtime model setup when top-level fields are absent", () => {
expect(
buildDraftAgentSetup(
createAgent({
currentModeId: null,
model: null,
thinkingOptionId: null,
}),
),
).toMatchObject({
modeId: "runtime-mode",
model: "runtime-model",
thinkingOptionId: "runtime-thinking",
});
});
});
@@ -1,90 +0,0 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert";
export interface ClientSlashCommand {
name: string;
description: string;
argumentHint: string;
kind: ClientSlashCommandKind;
execution: ClientSlashCommandExecution;
}
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
{
name: "quit",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "exit",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "q",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "clear",
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
},
{
name: "new",
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
},
];
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
export function resolveClientSlashCommand(input: {
text: string;
hasAttachments: boolean;
}): ClientSlashCommand | null {
if (input.hasAttachments) {
return null;
}
const trimmed = input.text.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const commandName = trimmed.slice(1);
if (!commandName || /\s/.test(commandName)) {
return null;
}
return COMMAND_BY_NAME.get(commandName) ?? null;
}
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
featureValues,
};
}
@@ -12,45 +12,14 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { ArrowLeft, Search, X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
// Horizontal indent token shared by the sheet header (title, back arrow,
// leading icon, search input icon) and any row primitive rendered inside the
// sheet body. Rows whose leading icon should line up with the header must
// match this padding.
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
export interface SheetHeaderSearch {
value: string;
onChange: (value: string) => void;
initialValue?: string;
resetKey?: string | number;
placeholder?: string;
autoFocus?: boolean;
testID?: string;
}
export interface SheetHeaderBack {
onPress: () => void;
label?: string;
accessibilityLabel?: string;
}
export interface SheetHeader {
title: string;
subtitle?: ReactNode;
back?: SheetHeaderBack;
leading?: ReactNode;
actions?: ReactNode;
search?: SheetHeaderSearch;
}
type EscHandler = () => void; type EscHandler = () => void;
const escStack: EscHandler[] = []; const escStack: EscHandler[] = [];
@@ -103,30 +72,23 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.surface2, borderColor: theme.colors.surface2,
}, },
headerContainer: { header: {
borderBottomWidth: 1, paddingHorizontal: theme.spacing[6],
borderBottomColor: theme.colors.surface2,
},
headerRow: {
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
gap: theme.spacing[2], justifyContent: "space-between",
}, borderBottomWidth: 1,
headerBackButton: { borderBottomColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg, gap: theme.spacing[3],
},
headerLeadingSlot: {
alignItems: "center",
justifyContent: "center",
}, },
headerTitleGroup: { headerTitleGroup: {
flex: 1, flex: 1,
gap: theme.spacing[1], gap: theme.spacing[2],
minWidth: 0, minWidth: 0,
}, },
title: { title: {
flex: 1,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
@@ -134,78 +96,52 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
}, },
closeButton: { closeButton: {
padding: theme.spacing[2], padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, backgroundColor: theme.colors.surface2,
searchRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingBottom: theme.spacing[3],
},
// Inline variants for InlineHeaderView inside the desktop Combobox popover.
// Horizontal padding matches the model picker's row indent: the picker uses
// children mode (desktopChildrenScrollContent, no scroll padding), so the
// row content starts at item.paddingHorizontal = spacing[3].
inlineHeaderRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inlineSearchRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
inlineTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
}, },
desktopScroll: { desktopScroll: {
flexShrink: 1, flexShrink: 1,
minHeight: 0, minHeight: 0,
}, },
desktopContent: { desktopContent: {
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], padding: theme.spacing[6],
gap: theme.spacing[4], gap: theme.spacing[4],
flexGrow: 1, flexGrow: 1,
}, },
bottomSheetHeader: {
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[3],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
bottomSheetContent: { bottomSheetContent: {
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], padding: theme.spacing[6],
gap: theme.spacing[4], gap: theme.spacing[4],
}, },
bottomSheetStaticContent: { bottomSheetStaticContent: {
flex: 1, flex: 1,
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], padding: theme.spacing[6],
gap: theme.spacing[4], gap: theme.spacing[4],
minHeight: 0, minHeight: 0,
}, },
desktopStaticContent: { desktopStaticContent: {
flexShrink: 1, flexShrink: 1,
minHeight: 0, minHeight: 0,
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], padding: theme.spacing[6],
gap: theme.spacing[4], gap: theme.spacing[4],
}, },
})); }));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
function SheetBackground({ style }: BottomSheetBackgroundProps) { function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const combinedStyle = useMemo( const combinedStyle = useMemo(
@@ -222,186 +158,14 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <View style={combinedStyle} />; return <View style={combinedStyle} />;
} }
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
};
// React Native controlled TextInput can replay stale JS values during fast input
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
// can seed it once with initialValue and remount with resetKey for real resets.
// See https://github.com/facebook/react-native/issues/44157
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
const textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
};
if (isMobile && isNative) {
return (
<BottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
export function SheetHeaderView({
header,
onClose,
showCloseButton = true,
testID,
}: {
header: SheetHeader;
onClose: () => void;
showCloseButton?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const back = header.back;
const handleBackPress = back?.onPress;
const search = header.search;
const handleSearchChange = useCallback(
(value: string) => {
search?.onChange(value);
},
[search],
);
return (
<View style={styles.headerContainer} testID={testID}>
<View style={styles.headerRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={18}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1}>
{header.title}
</Text>
{header.subtitle}
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
{({ pressed }) => (
<X
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
</View>
{search ? (
<View style={styles.searchRow}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
onChangeText={handleSearchChange}
autoCapitalize="none"
autoCorrect={false}
autoFocus={search.autoFocus}
testID={search.testID}
/>
</View>
) : null}
</View>
);
}
export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading);
if (!hasInlineRow && !header.search) return null;
return (
<View>
{hasInlineRow ? (
<View style={styles.inlineHeaderRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
</View>
) : null}
{header.search ? (
<View style={styles.inlineSearchRow}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}
onChangeText={header.search.onChange}
autoCapitalize="none"
autoCorrect={false}
autoFocus={header.search.autoFocus}
testID={header.search.testID}
/>
</View>
) : null}
</View>
);
}
export interface AdaptiveModalSheetProps { export interface AdaptiveModalSheetProps {
header: SheetHeader; title: string;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[]; snapPoints?: string[];
testID?: string; testID?: string;
/** Override the max width of the desktop card. */ /** Override the max width of the desktop card. */
@@ -412,10 +176,12 @@ export interface AdaptiveModalSheetProps {
} }
export function AdaptiveModalSheet({ export function AdaptiveModalSheet({
header, title,
subtitle,
visible, visible,
onClose, onClose,
children, children,
headerActions,
snapPoints, snapPoints,
testID, testID,
desktopMaxWidth, desktopMaxWidth,
@@ -424,6 +190,7 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo( const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }), () => ({ backgroundColor: theme.colors.surface2 }),
@@ -442,6 +209,7 @@ export function AdaptiveModalSheet({
[], [],
); );
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo( const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }], () => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth], [desktopMaxWidth],
@@ -469,7 +237,18 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
accessible={false} accessible={false}
> >
<SheetHeaderView header={header} onClose={onClose} testID={testID} /> <View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? ( {scrollable ? (
<BottomSheetScrollView <BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent} contentContainerStyle={styles.bottomSheetContent}
@@ -487,7 +266,18 @@ export function AdaptiveModalSheet({
const cardInner = ( const cardInner = (
<> <>
<SheetHeaderView header={header} onClose={onClose} /> <View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? ( {scrollable ? (
<ScrollView <ScrollView
style={styles.desktopScroll} style={styles.desktopScroll}
@@ -534,3 +324,19 @@ export function AdaptiveModalSheet({
</Modal> </Modal>
); );
} }
/**
* TextInput that automatically uses BottomSheetTextInput on mobile
* for proper keyboard dodging in AdaptiveModalSheet.
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
if (isMobile) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}
return <TextInput ref={ref} {...props} />;
},
);
@@ -2,11 +2,9 @@ import { useCallback } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
option: { option: {
flexDirection: "row", flexDirection: "row",
@@ -64,7 +62,7 @@ export function AddHostMethodModal({
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={ADD_CONNECTION_HEADER} title="Add connection"
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
testID="add-host-method-modal" testID="add-host-method-modal"
+3 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useReducer, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -11,11 +11,10 @@ import {
serializeConnectionUriForStorage, serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints"; } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
interface DirectConnectionDraft { interface DirectConnectionDraft {
host: string; host: string;
@@ -280,7 +279,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState(""); const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => { const clearInput = useCallback(() => {
setHost(""); setHost("");
@@ -290,7 +288,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false); setIsPasswordVisible(false);
setIsAdvancedOpen(false); setIsAdvancedOpen(false);
setAdvancedUri(""); setAdvancedUri("");
bumpInputResetKey();
}, []); }, []);
const connectIcon = useMemo( const connectIcon = useMemo(
@@ -414,7 +411,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setUseTls(next.useTls); setUseTls(next.useTls);
setPassword(next.password); setPassword(next.password);
setErrorMessage(""); setErrorMessage("");
bumpInputResetKey();
} catch { } catch {
setErrorMessage(""); setErrorMessage("");
} }
@@ -426,7 +422,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={DIRECT_CONNECTION_HEADER} title="Direct connection"
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
testID="add-host-modal" testID="add-host-modal"
@@ -440,8 +436,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-input" testID="direct-host-input"
nativeID="direct-host-input" nativeID="direct-host-input"
accessibilityLabel="Host" accessibilityLabel="Host"
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host} value={host}
onChangeText={setHost} onChangeText={setHost}
placeholder="localhost" placeholder="localhost"
@@ -460,8 +454,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-port-input" testID="direct-port-input"
nativeID="direct-port-input" nativeID="direct-port-input"
accessibilityLabel="Port" accessibilityLabel="Port"
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port} value={port}
onChangeText={setPort} onChangeText={setPort}
placeholder="6767" placeholder="6767"
@@ -503,8 +495,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-password-input" testID="direct-password-input"
nativeID="direct-password-input" nativeID="direct-password-input"
accessibilityLabel="Password" accessibilityLabel="Password"
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
placeholder="Optional" placeholder="Optional"
@@ -547,8 +537,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-uri-input" testID="direct-host-uri-input"
nativeID="direct-host-uri-input" nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI" accessibilityLabel="Connection URI"
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri} value={advancedUri}
onChangeText={setAdvancedUri} onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true" placeholder="tcp://localhost:6767?ssl=true"
@@ -1,13 +1,9 @@
import { useCallback, useMemo, useReducer, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg"; import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
buildAcpProviderConfigPatch, buildAcpProviderConfigPatch,
@@ -30,7 +26,6 @@ type InstallState = "installed" | "available";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const ACTION_BUTTON_STYLE = { width: 92 } as const; const ACTION_BUTTON_STYLE = { width: 92 } as const;
const MODAL_SNAP_POINTS = ["78%", "92%"]; const MODAL_SNAP_POINTS = ["78%", "92%"];
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
const SEARCH_ICON_SIZE = 16; const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24; const PROVIDER_REMOTE_ICON_SIZE = 24;
@@ -143,15 +138,8 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId); const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId); const { patchConfig } = useDaemonConfig(serverId);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null); const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const handleClose = useCallback(() => {
setSearch("");
bumpSearchResetKey();
onClose();
}, [onClose]);
const installedProviderIds = useMemo( const installedProviderIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []), () => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries], [providerEntries],
@@ -169,7 +157,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
try { try {
await patchConfig(buildAcpProviderConfigPatch(entry)); await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]); await refresh([entry.id]);
handleClose(); onClose();
} catch (installError) { } catch (installError) {
Alert.alert( Alert.alert(
"Unable to install provider", "Unable to install provider",
@@ -179,14 +167,14 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
setInstallingProviderId((current) => (current === entry.id ? null : current)); setInstallingProviderId((current) => (current === entry.id ? null : current));
} }
}, },
[installingProviderId, handleClose, patchConfig, refresh], [installingProviderId, onClose, patchConfig, refresh],
); );
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={ADD_PROVIDER_HEADER} title="Add provider"
visible={visible} visible={visible}
onClose={handleClose} onClose={onClose}
desktopMaxWidth={680} desktopMaxWidth={680}
snapPoints={MODAL_SNAP_POINTS} snapPoints={MODAL_SNAP_POINTS}
testID="add-provider-modal" testID="add-provider-modal"
@@ -198,8 +186,6 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
<AdaptiveTextInput <AdaptiveTextInput
testID="provider-catalog-search" testID="provider-catalog-search"
accessibilityLabel="Search providers" accessibilityLabel="Search providers"
initialValue={search}
resetKey={`provider-catalog-search-${searchResetKey}`}
value={search} value={search}
onChangeText={setSearch} onChangeText={setSearch}
placeholder="Search providers" placeholder="Search providers"
@@ -230,7 +216,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
) : null} ) : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}> <Button style={FLEX_ONE_STYLE} variant="secondary" onPress={onClose}>
Cancel Cancel
</Button> </Button>
</View> </View>
+267 -277
View File
@@ -27,7 +27,6 @@ import {
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
ShieldQuestionMark,
Zap, Zap,
} from "lucide-react-native"; } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -48,7 +47,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { import type {
AgentFeature, AgentFeature,
@@ -64,7 +63,7 @@ import {
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb as platformIsWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
@@ -181,7 +180,6 @@ const MODE_ICONS = {
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
ShieldOff, ShieldOff,
ShieldQuestionMark,
} as const; } as const;
function alwaysTrue() { function alwaysTrue() {
@@ -199,16 +197,6 @@ function resolveDisplayModel(
return findOptionLabel(modelOptions, selectedModelId, "Select model"); return findOptionLabel(modelOptions, selectedModelId, "Select model");
} }
// Mobile status bar only — strip namespace prefix so providers like OpenCode
// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in
// the model picker.
function shortModelLabel(label: string): string {
const i = label.lastIndexOf("/");
return i === -1 ? label : label.slice(i + 1);
}
type ActiveSheet = "thinking" | "mode" | "features" | null;
function resolveHasAnyControl({ function resolveHasAnyControl({
providerOptions, providerOptions,
modeOptions, modeOptions,
@@ -268,6 +256,18 @@ function makeBadgePressableStyle(
]; ];
} }
function makeSheetPressableStyle(isDisabled: boolean) {
return ({ pressed }: PressableStateCallbackType) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
isDisabled && styles.disabledSheetSelect,
];
}
function makePrefsButtonStyle({ pressed }: PressableStateCallbackType) {
return [styles.prefsButton, pressed && styles.prefsButtonPressed];
}
function pickSheetModel({ function pickSheetModel({
nextProviderId, nextProviderId,
modelId, modelId,
@@ -418,6 +418,27 @@ function buildOpenChangeHandler(
}; };
} }
function SheetModelTriggerView({
selectedModelLabel,
style,
ProviderIcon,
}: {
selectedModelLabel: string;
style: StyleProp<ViewStyle>;
ProviderIcon: ReturnType<typeof getProviderIcon> | null;
}) {
const { theme } = useUnistyles();
return (
<View style={style} pointerEvents="none" testID="agent-preferences-model">
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
);
}
function getModeIconColor( function getModeIconColor(
colorTier: AgentModeColorTier | undefined, colorTier: AgentModeColorTier | undefined,
palette: { palette: {
@@ -469,8 +490,7 @@ function ControlledStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor(); const [prefsOpen, setPrefsOpen] = useState(false);
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null); const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null); const providerAnchorRef = useRef<View>(null);
@@ -488,6 +508,7 @@ function ControlledStatusBar({
); );
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId); const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId);
const displayThinking = findOptionLabel( const displayThinking = findOptionLabel(
thinkingOptions, thinkingOptions,
@@ -547,18 +568,6 @@ function ControlledStatusBar({
), ),
[provider, providerDefinitions, theme.colors.foreground], [provider, providerDefinitions, theme.colors.foreground],
); );
const renderThinkingOption = useCallback(
(args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => (
<ThinkingComboboxOption
option={args.option}
selected={args.selected}
active={args.active}
onPress={args.onPress}
iconColor={theme.colors.foreground}
/>
),
[theme.colors.foreground],
);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(selector: StatusSelector) => (selector: StatusSelector) =>
@@ -632,30 +641,16 @@ function ControlledStatusBar({
[canSelectMode, disabled, openSelector], [canSelectMode, disabled, openSelector],
); );
const handleOpenSheet = useCallback((sheet: Exclude<ActiveSheet, null>) => { const handleOpenPrefs = useCallback(() => {
Keyboard.dismiss(); Keyboard.dismiss();
setActiveSheet(sheet); setPrefsOpen(true);
}, []); }, []);
const handleCloseSheet = useCallback(() => { const handleClosePrefs = useCallback(() => {
setActiveSheet(null); setPrefsOpen(false);
}, []); }, []);
const handleSelectThinkingAndClose = useCallback( const prefsButtonStyle = makePrefsButtonStyle;
(thinkingOptionId: string) => {
onSelectThinkingOption?.(thinkingOptionId);
setActiveSheet(null);
},
[onSelectThinkingOption],
);
const handleSelectModeAndClose = useCallback(
(modeId: string) => {
onSelectMode?.(modeId);
setActiveSheet(null);
},
[onSelectMode],
);
const handleSheetModelSelect = useCallback( const handleSheetModelSelect = useCallback(
(nextProviderId: string, modelId: string) => { (nextProviderId: string, modelId: string) => {
@@ -671,13 +666,38 @@ function ControlledStatusBar({
[onSelectModel, onSelectProvider, onSelectProviderAndModel, provider], [onSelectModel, onSelectProvider, onSelectProviderAndModel, provider],
); );
const sheetThinkingPressableStyle = useMemo(
() => makeSheetPressableStyle(disabled || !canSelectThinking),
[canSelectThinking, disabled],
);
const sheetModePressableStyle = useMemo(
() => makeSheetPressableStyle(disabled || !canSelectMode),
[canSelectMode, disabled],
);
const sheetSelectStyle = useMemo(
() => [styles.sheetSelect, modelDisabled && styles.disabledSheetSelect],
[modelDisabled],
);
const renderSheetModelTrigger = useCallback(
({ selectedModelLabel }: { selectedModelLabel: string }) => (
<SheetModelTriggerView
selectedModelLabel={selectedModelLabel}
style={sheetSelectStyle}
ProviderIcon={ProviderIcon}
/>
),
[ProviderIcon, sheetSelectStyle],
);
if (!hasAnyControl) { if (!hasAnyControl) {
return null; return null;
} }
return ( return (
<View style={styles.container}> <View style={styles.container}>
{!isCompact ? ( {platformIsWeb ? (
<DesktopStatusBarContent <DesktopStatusBarContent
provider={provider} provider={provider}
providerOptions={providerOptions} providerOptions={providerOptions}
@@ -732,19 +752,23 @@ function ControlledStatusBar({
handleModeOpenChange={handleModeOpenChange} handleModeOpenChange={handleModeOpenChange}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
renderModeOption={renderModeOption} renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/> />
) : ( ) : (
<SheetStatusBarContent <SheetStatusBarContent
provider={provider} provider={provider}
modeOptions={modeOptions}
selectedModeId={selectedModeId} selectedModeId={selectedModeId}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
thinkingOptions={thinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId} selectedThinkingOptionId={selectedThinkingOptionId}
features={features} features={features}
onSetFeature={onSetFeature} onSetFeature={onSetFeature}
onSelectMode={onSelectMode}
onSelectThinkingOption={onSelectThinkingOption}
onToggleFavoriteModel={onToggleFavoriteModel} onToggleFavoriteModel={onToggleFavoriteModel}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen} onModelSelectorOpen={onModelSelectorOpen}
providerDefinitions={providerDefinitions}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
disabled={disabled} disabled={disabled}
isModelLoading={isModelLoading} isModelLoading={isModelLoading}
@@ -755,21 +779,24 @@ function ControlledStatusBar({
modelDisabled={modelDisabled} modelDisabled={modelDisabled}
effectiveProviderDefinitions={effectiveProviderDefinitions} effectiveProviderDefinitions={effectiveProviderDefinitions}
effectiveAllProviderModels={effectiveAllProviderModels} effectiveAllProviderModels={effectiveAllProviderModels}
comboboxModeOptions={comboboxModeOptions} displayMode={displayMode}
comboboxThinkingOptions={comboboxThinkingOptions} displayModel={displayModel}
displayThinking={displayThinking}
ModeIconComponent={ModeIconComponent} ModeIconComponent={ModeIconComponent}
modeIconColor={modeIconColor} modeIconColor={modeIconColor}
openSelector={openSelector} openSelector={openSelector}
ProviderIcon={ProviderIcon} ProviderIcon={ProviderIcon}
activeSheet={activeSheet} prefsOpen={prefsOpen}
handleOpenSheet={handleOpenSheet} handleOpenPrefs={handleOpenPrefs}
handleCloseSheet={handleCloseSheet} handleClosePrefs={handleClosePrefs}
prefsButtonStyle={prefsButtonStyle}
sheetThinkingPressableStyle={sheetThinkingPressableStyle}
sheetModePressableStyle={sheetModePressableStyle}
handleSheetModelSelect={handleSheetModelSelect} handleSheetModelSelect={handleSheetModelSelect}
handleSelectThinkingAndClose={handleSelectThinkingAndClose} handleThinkingOpenChange={handleThinkingOpenChange}
handleSelectModeAndClose={handleSelectModeAndClose} handleModeOpenChange={handleModeOpenChange}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
renderModeOption={renderModeOption} renderSheetModelTrigger={renderSheetModelTrigger}
renderThinkingOption={renderThinkingOption}
/> />
)} )}
</View> </View>
@@ -835,12 +862,6 @@ interface DesktopStatusBarContentProps {
active: boolean; active: boolean;
onPress: () => void; onPress: () => void;
}) => ReactElement; }) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
} }
const DESKTOP_SEARCH_THRESHOLD = 6; const DESKTOP_SEARCH_THRESHOLD = 6;
@@ -899,7 +920,6 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
handleModeOpenChange, handleModeOpenChange,
handleOpenChange, handleOpenChange,
renderModeOption, renderModeOption,
renderThinkingOption,
} = props; } = props;
return ( return (
@@ -995,7 +1015,6 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
onOpenChange={handleThinkingOpenChange} onOpenChange={handleThinkingOpenChange}
anchorRef={thinkingAnchorRef} anchorRef={thinkingAnchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
renderOption={renderThinkingOption}
/> />
</> </>
) : null} ) : null}
@@ -1055,14 +1074,19 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
interface SheetStatusBarContentProps { interface SheetStatusBarContentProps {
provider: string; provider: string;
modeOptions?: StatusOption[];
selectedModeId?: string; selectedModeId?: string;
selectedModelId?: string; selectedModelId?: string;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string; selectedThinkingOptionId?: string;
features?: AgentFeature[]; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onSelectMode?: (modeId: string) => void;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
onToggleFavoriteModel?: (provider: string, modelId: string) => void; onToggleFavoriteModel?: (provider: string, modelId: string) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
providerDefinitions: AgentProviderDefinition[];
favoriteKeys: Set<string>; favoriteKeys: Set<string>;
disabled: boolean; disabled: boolean;
isModelLoading: boolean; isModelLoading: boolean;
@@ -1073,45 +1097,43 @@ interface SheetStatusBarContentProps {
modelDisabled: boolean; modelDisabled: boolean;
effectiveProviderDefinitions: AgentProviderDefinition[]; effectiveProviderDefinitions: AgentProviderDefinition[];
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>; effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
comboboxModeOptions: ComboboxOption[]; displayMode: string;
comboboxThinkingOptions: ComboboxOption[]; displayModel: string;
displayThinking: string;
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null; ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
modeIconColor: string; modeIconColor: string;
openSelector: StatusSelector | null; openSelector: StatusSelector | null;
ProviderIcon: ReturnType<typeof getProviderIcon> | null; ProviderIcon: ReturnType<typeof getProviderIcon> | null;
activeSheet: ActiveSheet; prefsOpen: boolean;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void; handleOpenPrefs: () => void;
handleCloseSheet: () => void; handleClosePrefs: () => void;
prefsButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetThinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetModePressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
handleSheetModelSelect: (providerId: string, modelId: string) => void; handleSheetModelSelect: (providerId: string, modelId: string) => void;
handleSelectThinkingAndClose: (thinkingOptionId: string) => void; handleThinkingOpenChange: (open: boolean) => void;
handleSelectModeAndClose: (modeId: string) => void; handleModeOpenChange: (open: boolean) => void;
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void; handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void;
renderModeOption: (args: { renderSheetModelTrigger: (args: { selectedModelLabel: string }) => ReactElement;
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
} }
function SheetStatusBarContent(props: SheetStatusBarContentProps) { function SheetStatusBarContent(props: SheetStatusBarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { const {
provider, provider,
modeOptions,
selectedModeId, selectedModeId,
selectedModelId, selectedModelId,
thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
features, features,
onSetFeature, onSetFeature,
onSelectMode,
onSelectThinkingOption,
onToggleFavoriteModel, onToggleFavoriteModel,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
providerDefinitions,
favoriteKeys, favoriteKeys,
disabled, disabled,
isModelLoading, isModelLoading,
@@ -1122,194 +1144,133 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
modelDisabled, modelDisabled,
effectiveProviderDefinitions, effectiveProviderDefinitions,
effectiveAllProviderModels, effectiveAllProviderModels,
comboboxModeOptions, displayMode,
comboboxThinkingOptions, displayModel,
displayThinking,
ModeIconComponent, ModeIconComponent,
modeIconColor, modeIconColor,
openSelector, openSelector,
ProviderIcon, ProviderIcon,
activeSheet, prefsOpen,
handleOpenSheet, handleOpenPrefs,
handleCloseSheet, handleClosePrefs,
prefsButtonStyle,
sheetThinkingPressableStyle,
sheetModePressableStyle,
handleSheetModelSelect, handleSheetModelSelect,
handleSelectThinkingAndClose, handleThinkingOpenChange,
handleSelectModeAndClose, handleModeOpenChange,
handleOpenChange, handleOpenChange,
renderModeOption, renderSheetModelTrigger,
renderThinkingOption,
} = props; } = props;
const thinkingAnchorRef = useRef<View | null>(null); return (
const modeAnchorRef = useRef<View | null>(null); <>
<Pressable
const hasThinking = comboboxThinkingOptions.length > 0; onPress={handleOpenPrefs}
const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0); style={prefsButtonStyle}
const hasFeatures = Boolean(features && features.length > 0); accessibilityRole="button"
accessibilityLabel="Agent preferences"
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); testID="agent-preferences-button"
const handleOpenMode = useCallback(() => handleOpenSheet("mode"), [handleOpenSheet]); >
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
const handleThinkingSheetOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
handleOpenSheet("thinking");
} else {
handleCloseSheet();
}
},
[handleCloseSheet, handleOpenSheet],
);
const handleModeSheetOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
handleOpenSheet("mode");
} else {
handleCloseSheet();
}
},
[handleCloseSheet, handleOpenSheet],
);
const renderModelTrigger = useCallback(
({
selectedModelLabel,
}: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
}) => (
<View pointerEvents="none" style={styles.prefsButton} testID="agent-status-bar-model">
{ProviderIcon ? ( {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null} ) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}> <Text style={styles.prefsButtonText} numberOfLines={1}>
{shortModelLabel(selectedModelLabel)} {displayModel}
</Text> </Text>
</View> </Pressable>
),
[ProviderIcon, theme.iconSize.lg, theme.colors.foregroundMuted],
);
const thinkingButtonStyle = makeBadgePressableStyle(
styles.modeIconBadge,
styles.disabledBadge,
disabled || !canSelectThinking,
activeSheet === "thinking",
);
const modeButtonStyle = makeBadgePressableStyle(
styles.modeIconBadge,
styles.disabledBadge,
disabled || !canSelectMode,
activeSheet === "mode",
);
const featuresButtonStyle = makeBadgePressableStyle(
styles.modeIconBadge,
styles.disabledBadge,
disabled,
activeSheet === "features",
);
return (
<>
{canSelectModel ? (
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderModelTrigger}
/>
) : null}
{hasThinking ? (
<Pressable
ref={thinkingAnchorRef}
onPress={handleOpenThinking}
disabled={disabled || !canSelectThinking}
style={thinkingButtonStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-status-bar-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
{hasMode ? (
<Pressable
ref={modeAnchorRef}
onPress={handleOpenMode}
disabled={disabled || !canSelectMode}
style={modeButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeId ?? ""})`}
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
) : null}
{hasFeatures ? (
<Pressable
onPress={handleOpenFeatures}
disabled={disabled}
style={featuresButtonStyle}
accessibilityRole="button"
accessibilityLabel="Open agent features"
testID="agent-status-bar-features"
>
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
{hasThinking ? (
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ""}
onSelect={handleSelectThinkingAndClose}
searchable={false}
title="Thinking"
open={activeSheet === "thinking"}
onOpenChange={handleThinkingSheetOpenChange}
anchorRef={thinkingAnchorRef}
renderOption={renderThinkingOption}
/>
) : null}
{hasMode ? (
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ""}
onSelect={handleSelectModeAndClose}
searchable={false}
title="Mode"
open={activeSheet === "mode"}
onOpenChange={handleModeSheetOpenChange}
renderOption={renderModeOption}
anchorRef={modeAnchorRef}
/>
) : null}
<AdaptiveModalSheet <AdaptiveModalSheet
header={FEATURES_SHEET_HEADER} title="Preferences"
visible={activeSheet === "features"} visible={prefsOpen}
onClose={handleCloseSheet} onClose={handleClosePrefs}
testID="agent-features-sheet" testID="agent-preferences-sheet"
> >
{(features ?? []).map((feature) => ( {canSelectModel ? (
<View style={styles.sheetSection}>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderSheetModelTrigger}
/>
</View>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "thinking"}
onOpenChange={handleThinkingOpenChange}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={sheetThinkingPressableStyle}
accessibilityRole="button"
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>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((thinking) => (
<ThinkingMenuItem
key={thinking.id}
thinking={thinking}
selected={thinking.id === selectedThinkingOptionId}
onSelectThinkingOption={onSelectThinkingOption}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu open={openSelector === "mode"} onOpenChange={handleModeOpenChange}>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={sheetModePressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => (
<ModeMenuItem
key={mode.id}
mode={mode}
provider={provider}
providerDefinitions={providerDefinitions}
selected={mode.id === selectedModeId}
onSelectMode={onSelectMode}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => (
<SheetFeatureItem <SheetFeatureItem
key={`feature-${feature.id}`} key={`feature-${feature.id}`}
feature={feature} feature={feature}
@@ -1576,28 +1537,23 @@ function FeatureOptionMenuItem({
); );
} }
function ThinkingComboboxOption({ function ThinkingMenuItem({
option, thinking,
selected, selected,
active, onSelectThinkingOption,
onPress,
iconColor,
}: { }: {
option: ComboboxOption; thinking: StatusOption;
selected: boolean; selected: boolean;
active: boolean; onSelectThinkingOption?: (thinkingOptionId: string) => void;
onPress: () => void;
iconColor: string;
}) { }) {
const leadingSlot = useMemo(() => <Brain size={16} color={iconColor} />, [iconColor]); const handleSelect = useCallback(() => {
onSelectThinkingOption?.(thinking.id);
}, [onSelectThinkingOption, thinking.id]);
return ( return (
<ComboboxItem <DropdownMenuItem selected={selected} onSelect={handleSelect}>
label={option.label} {thinking.label}
selected={selected} </DropdownMenuItem>
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
); );
} }
@@ -1635,8 +1591,40 @@ function ModeComboboxOption({
); );
} }
function ModeMenuItem({
mode,
provider,
providerDefinitions,
selected,
onSelectMode,
}: {
mode: StatusOption;
provider: string;
providerDefinitions: AgentProviderDefinition[];
selected: boolean;
onSelectMode?: (modeId: string) => void;
}) {
const { theme } = useUnistyles();
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const handleSelect = useCallback(() => {
onSelectMode?.(mode.id);
}, [mode.id, onSelectMode]);
const leadingIcon = useMemo(
() => <Icon size={16} color={theme.colors.foreground} />,
[Icon, theme.colors.foreground],
);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leadingIcon}>
{mode.label}
</DropdownMenuItem>
);
}
const EMPTY_MODES: AgentMode[] = []; const EMPTY_MODES: AgentMode[] = [];
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentStatusBar = memo(function AgentStatusBar({ export const AgentStatusBar = memo(function AgentStatusBar({
agentId, agentId,
@@ -1885,7 +1873,6 @@ export function DraftAgentStatusBar({
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) { if (modeOptions.length === 0) {
@@ -1933,7 +1920,7 @@ export function DraftAgentStatusBar({
[updatePreferences], [updatePreferences],
); );
if (!isCompact) { if (platformIsWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<CombinedModelSelector <CombinedModelSelector
@@ -2047,6 +2034,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"], borderRadius: theme.borderRadius["2xl"],
}, },
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
prefsButtonText: { prefsButtonText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -35,7 +35,6 @@ describe("buildAgentStreamRenderModel", () => {
const head = [assistantMessage("live-a", 121)]; const head = [assistantMessage("live-a", 121)];
const model = buildAgentStreamRenderModel({ const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head, head,
platform: "web", platform: "web",
@@ -53,7 +52,6 @@ describe("buildAgentStreamRenderModel", () => {
const head = [assistantMessage("live-a", 3)]; const head = [assistantMessage("live-a", 3)];
const model = buildAgentStreamRenderModel({ const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head, head,
platform: "web", platform: "web",
@@ -71,14 +69,12 @@ describe("buildAgentStreamRenderModel", () => {
const secondHead = [assistantMessage("live-b", 4)]; const secondHead = [assistantMessage("live-b", 4)];
const first = buildAgentStreamRenderModel({ const first = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head: firstHead, head: firstHead,
platform: "native", platform: "native",
isMobileBreakpoint: false, isMobileBreakpoint: false,
}); });
const second = buildAgentStreamRenderModel({ const second = buildAgentStreamRenderModel({
agentStatus: "running",
tail, tail,
head: secondHead, head: secondHead,
platform: "native", platform: "native",
@@ -89,73 +85,4 @@ describe("buildAgentStreamRenderModel", () => {
expect(first.segments.historyMounted).toBe(second.segments.historyMounted); expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]); expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
}); });
it("derives running turn timing across committed history and live head", () => {
const tail = [userMessage("u1", 1)];
const head = [assistantMessage("live-a", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
});
it("maps completed turn timing to assistant ids across committed history and live head", () => {
const tail = [userMessage("u1", 1)];
const head = [assistantMessage("live-a", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
});
it("derives the same timing for native inverted rendering", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head: [],
platform: "native",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
});
it("does not create completed timing for adjacent user messages", () => {
const tail = [userMessage("u1", 1), userMessage("u2", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
}); });
@@ -1,5 +1,4 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { import {
findMountedWindowStart, findMountedWindowStart,
@@ -27,19 +26,17 @@ export interface StreamHistoryBoundary {
export interface StreamRenderAuxiliary { export interface StreamRenderAuxiliary {
pendingPermissions: ReactNode; pendingPermissions: ReactNode;
turnFooter: ReactNode; workingIndicator: ReactNode;
} }
export interface AgentStreamRenderModel { export interface AgentStreamRenderModel {
history: StreamItem[]; history: StreamItem[];
segments: StreamRenderSegments; segments: StreamRenderSegments;
turnTiming: StreamTurnTiming;
boundary: StreamHistoryBoundary; boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary; auxiliary: StreamRenderAuxiliary;
} }
export interface BuildAgentStreamRenderModelInput { export interface BuildAgentStreamRenderModelInput {
agentStatus: string;
tail: StreamItem[]; tail: StreamItem[];
head: StreamItem[]; head: StreamItem[];
platform: "web" | "native"; platform: "web" | "native";
@@ -49,7 +46,7 @@ export interface BuildAgentStreamRenderModelInput {
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = { const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null, pendingPermissions: null,
turnFooter: null, workingIndicator: null,
}; };
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>(); const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
@@ -58,10 +55,6 @@ const splitHistoryCache = new WeakMap<
StreamItem[], StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">> Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>(); >();
const turnTimingCache = new WeakMap<
StreamItem[],
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
>();
function getOrderedItems(params: { function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>; cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
@@ -134,30 +127,6 @@ function splitOrderedTail(params: {
return split; return split;
} }
function getTurnTiming(params: {
agentStatus: string;
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
let cachedByHead = turnTimingCache.get(params.tail);
if (!cachedByHead) {
cachedByHead = new WeakMap();
turnTimingCache.set(params.tail, cachedByHead);
}
let cachedByStatus = cachedByHead.get(params.head);
if (!cachedByStatus) {
cachedByStatus = new Map();
cachedByHead.set(params.head, cachedByStatus);
}
const cached = cachedByStatus.get(params.agentStatus);
if (cached) {
return cached;
}
const timing = deriveStreamTurnTiming(params);
cachedByStatus.set(params.agentStatus, timing);
return timing;
}
export function buildAgentStreamRenderModel( export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput, input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel { ): AgentStreamRenderModel {
@@ -191,11 +160,6 @@ export function buildAgentStreamRenderModel(
platform: input.platform, platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint, isMobileBreakpoint: input.isMobileBreakpoint,
}); });
const turnTiming = getTurnTiming({
agentStatus: input.agentStatus,
tail: input.tail,
head: input.head,
});
return { return {
history: splitHistory.history, history: splitHistory.history,
@@ -203,7 +167,6 @@ export function buildAgentStreamRenderModel(
...splitHistory.segments, ...splitHistory.segments,
liveHead: orderedHead, liveHead: orderedHead,
}, },
turnTiming,
boundary: { boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0, hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0, hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
@@ -1,222 +0,0 @@
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
import { SyncedLoader } from "./synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const usesLiveHead = input.liveHead.length > 0;
const footerItems = usesLiveHead ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
}) {
if (isRunning) {
return (
<TurnFooterRow>
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
</TurnFooterRow>
);
}
if (!host) {
return null;
}
return (
<CompletedTurnFooterRow
strategy={strategy}
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
/>
);
});
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
return (
<TurnFooterRow>
<CompletedTurnFooter
strategy={strategy}
items={items}
timing={timing}
startIndex={startIndex}
/>
</TurnFooterRow>
);
});
const WorkingIndicator = memo(function WorkingIndicator({
inFlightTurnStartedAt = null,
}: {
inFlightTurnStartedAt?: Date | null;
}) {
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingLoader}>
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
</View>
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
});
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
return (
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
);
}
function CompletedTurnFooter({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
/>
</View>
);
}
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
paddingBottom: theme.spacing[6],
},
turnFooterContent: {
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
workingElapsed: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
fontVariant: ["tabular-nums"],
},
workingLoader: {
marginLeft: -2,
},
}));
@@ -1,6 +1,20 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data"; import type { NeighborResolver } from "./agent-stream-view-data";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
// Minimal forward-order resolver: "below" = next item in array.
// Matches web strategy rendering order (chronological, top-to-bottom).
const forwardStrategy: NeighborResolver = {
getNeighborItem(items, index, relation) {
const neighborIndex = relation === "below" ? index + 1 : index - 1;
return items[neighborIndex];
},
};
function assistantBlock(params: { function assistantBlock(params: {
id: string; id: string;
@@ -121,3 +135,46 @@ describe("getAssistantBlockSpacing", () => {
).toBe("compactTop"); ).toBe("compactTop");
}); });
}); });
describe("resolveInlineWorkingIndicatorItemId", () => {
it("returns null when the agent is not running", () => {
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
});
it("returns the last assistant block id when running with a single head block", () => {
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
"group-1:head",
);
});
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
const tc = toolCallBlock("tool-1");
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
});
it("returns the footer assistant block when history and streaming head coexist", () => {
const historyBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
});
const streamingBlock = assistantBlock({
id: "group-2:head",
blockGroupId: "group-2",
blockIndex: 0,
});
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
"group-2:head",
);
expect(
resolveInlineWorkingIndicatorItemId(
"running",
[historyBlock, streamingBlock],
forwardStrategy,
),
).toBe("group-2:head");
});
});
@@ -1,5 +1,4 @@
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
export function isSameAssistantBlockGroup(params: { export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined; item: StreamItem | null | undefined;
@@ -29,35 +28,24 @@ export function getAssistantBlockSpacing(params: {
return "default"; return "default";
} }
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message"; export interface NeighborResolver {
const isToolSequenceItem = (item?: StreamItem | null) => getNeighborItem(
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; items: StreamItem[],
index: number,
relation: "above" | "below",
): StreamItem | undefined;
}
export function getGapBetweenStreamItems( // null → auxiliary working indicator; non-null → inline footer on that block.
item: StreamItem | null, export function resolveInlineWorkingIndicatorItemId(
belowItem: StreamItem | null, status: string,
): number { liveHeadItems: StreamItem[],
if (!item || !belowItem) { strategy: NeighborResolver,
return 0; ): string | null {
} if (status !== "running") return null;
const footerItem = liveHeadItems.find((item, index, items) => {
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { if (item.kind !== "assistant_message") return false;
return SPACING[1]; return strategy.getNeighborItem(items, index, "below") === undefined;
} });
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { return footerItem?.id ?? null;
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return SPACING[3];
}
return SPACING[4];
} }
+331 -281
View File
@@ -17,13 +17,20 @@ import {
Platform, Platform,
ActivityIndicator, ActivityIndicator,
type PressableStateCallbackType, type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, {
FadeIn,
FadeOut,
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native"; import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { import {
@@ -34,6 +41,7 @@ import {
ToolCall, ToolCall,
TodoListCard, TodoListCard,
CompactionMarker, CompactionMarker,
TurnCopyButton,
MessageOuterSpacingProvider, MessageOuterSpacingProvider,
type InlinePathTarget, type InlinePathTarget,
} from "./message"; } from "./message";
@@ -55,182 +63,42 @@ import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "./tool-call-sheet";
import { import {
buildAgentStreamRenderModel, buildAgentStreamRenderModel,
collectAssistantTurnContentForStreamRenderStrategy,
getStreamNeighborItem, getStreamNeighborItem,
resolveStreamRenderStrategy, resolveStreamRenderStrategy,
type AgentStreamRenderModel, type AgentStreamRenderModel,
type StreamSegmentRenderers, type StreamSegmentRenderers,
type StreamViewportHandle, type StreamViewportHandle,
} from "./agent-stream-render-strategy"; } from "./agent-stream-render-strategy";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
import { import {
CompletedTurnFooterRow, getAssistantBlockSpacing,
resolveBottomTurnFooterHost, isSameAssistantBlockGroup,
shouldRenderCompletedTurnFooter, resolveInlineWorkingIndicatorItemId,
TurnFooter, } from "./agent-stream-view-data";
type TurnContentStrategy,
type TurnFooterHost,
} from "./agent-stream-turn-footer";
import { import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/assistant-file-links"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { import { normalizeInlinePathTarget } from "@/utils/inline-path";
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
interface StreamItemBoundarySeams { interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null; aboveItem?: StreamItem | null;
belowItem?: StreamItem | null; belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
}): ReactNode {
if (!input.pendingPermissions && !input.turnFooter) {
return null;
}
return (
<>
{input.turnFooter}
{input.pendingPermissions ? (
<View style={stylesheet.contentWrapper}>
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
</View>
) : null}
</>
);
}
function renderPendingPermissionsNode(input: {
pendingPermissions: PendingPermission[];
client: DaemonClient | null;
}): ReactNode {
if (input.pendingPermissions.length === 0) {
return null;
}
return (
<View style={stylesheet.permissionsContainer}>
{input.pendingPermissions.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={input.client} />
))}
</View>
);
}
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
item: StreamItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
strategy: TurnContentStrategy;
}): ReactNode {
if (!input.content) {
return null;
}
const showCompletedFooter = shouldRenderCompletedTurnFooter({
item: input.item,
belowItem: input.nextItem,
agentStatus: input.agentStatus,
suppressTurnFooter: input.suppressTurnFooter,
});
const gapBelow = showCompletedFooter
? 0
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
return (
<>
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
{showCompletedFooter ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
</>
);
}
function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>;
}): ReactNode {
if (
input.renderModel.boundary.hasVirtualizedHistory ||
input.renderModel.boundary.hasMountedHistory ||
input.renderModel.boundary.hasLiveHead ||
input.renderModel.auxiliary.pendingPermissions ||
input.renderModel.auxiliary.turnFooter
) {
return null;
}
return (
<View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}
function renderHistoryStreamItem(input: {
item: StreamItem;
historyIndexById: Map<string, number>;
historyItems: StreamItem[];
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
const historyIndex = input.historyIndexById.get(input.item.id);
if (historyIndex === undefined) {
return null;
}
const seamBelowItem =
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
belowItem: seamBelowItem,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
index: number;
items: StreamItem[];
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
return input.renderStreamItem(input.item, input.index, input.items, {
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
} }
export interface AgentStreamViewHandle { export interface AgentStreamViewHandle {
@@ -247,11 +115,9 @@ export interface AgentStreamViewProps {
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null; toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void;
} }
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>( const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView( function AgentStreamView(
{ {
@@ -307,6 +173,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId, agentId,
toast, toast,
}); });
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -323,7 +194,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [agentId]); }, [agentId]);
const handleInlinePathPress = useCallback( const handleInlinePathPress = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => { (target: InlinePathTarget) => {
if (!target.path) { if (!target.path) {
return; return;
} }
@@ -334,20 +205,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
} }
if (normalized.file) { if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) { if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ openWorkspaceFile({ filePath: normalized.file });
location,
disposition,
});
return; return;
} }
@@ -355,7 +214,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
navigateToPreparedWorkspaceTab({ navigateToPreparedWorkspaceTab({
serverId: resolvedServerId, serverId: resolvedServerId,
workspaceId, workspaceId,
target: createWorkspaceFileTabTarget(location), target: { kind: "file", path: normalized.file },
}); });
} }
return; return;
@@ -386,26 +245,35 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
requestDirectoryListing, requestDirectoryListing,
resolvedServerId, resolvedServerId,
setExplorerTabForCheckout, setExplorerTabForCheckout,
openWorkspaceFile,
workspaceId, workspaceId,
], ],
); );
const handleToolCallOpenFile = useCallback( const handleToolCallOpenFile = useCallback(
(filePath: string) => { (filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "main"); handleInlinePathPress({ raw: filePath, path: filePath });
}, },
[handleInlinePathPress], [handleInlinePathPress],
); );
const baseRenderModel = useMemo(() => { const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
agentStatus: agent.status,
tail: streamItems, tail: streamItems,
head: streamHead ?? EMPTY_STREAM_HEAD, head: streamHead ?? [],
platform: isWeb ? "web" : "native", platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [agent.status, isMobile, streamHead, streamItems]); }, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(
() =>
resolveInlineWorkingIndicatorItemId(
agent.status,
baseRenderModel.segments.liveHead,
streamRenderStrategy,
),
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
@@ -423,6 +291,39 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom"); viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []); }, []);
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return looseGap;
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return assistantBlockGap;
}
return looseGap;
},
[assistantBlockGap, looseGap, tightGap],
);
const setInlineDetailsExpanded = useCallback( const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => { (itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) { if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
@@ -517,12 +418,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
serverId={serverId} serverId={serverId}
client={client} client={client}
toast={toast}
spacing={spacing} spacing={spacing}
/> />
); );
}, },
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast], [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
); );
const renderThoughtItem = useCallback( const renderThoughtItem = useCallback(
@@ -640,13 +540,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return <TodoListCard items={item.items} />; return <TodoListCard items={item.items} />;
case "compaction": case "compaction":
return ( return <CompactionMarker status={item.status} preTokens={item.preTokens} />;
<CompactionMarker
status={item.status}
trigger={item.trigger}
preTokens={item.preTokens}
/>
);
default: default:
return null; return null;
@@ -655,22 +549,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem], [renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
); );
const bottomTurnFooterHost = useMemo(() => {
return resolveBottomTurnFooterHost({
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
]);
const renderStreamItem = useCallback( const renderStreamItem = useCallback(
( (
item: StreamItem, item: StreamItem,
@@ -679,29 +557,45 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
seams: StreamItemBoundarySeams = {}, seams: StreamItemBoundarySeams = {},
) => { ) => {
const content = renderStreamItemContent(item, index, items, seams); const content = renderStreamItemContent(item, index, items, seams);
if (!content) {
return null;
}
const nextItem = getStreamNeighborItem({ const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
items, items,
index, index,
relation: "below", relation: "below",
}); });
return renderStreamItemWithTurnFooter({ const gapBelow = getGapBetween(item, nextItem ?? null);
content, const isEndOfAssistantTurn =
item, item.kind === "assistant_message" &&
nextItem, (nextItem?.kind === "user_message" ||
items, (nextItem === undefined && agent.status !== "running"));
timing: baseRenderModel.turnTiming.byAssistantId, const isRunningAssistantTurnFooter =
index, item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
agentStatus: agent.status, let footer: ReactNode = null;
suppressTurnFooter: seams.suppressTurnFooter, if (isRunningAssistantTurnFooter) {
strategy: streamRenderStrategy, footer = <InlineWorkingIndicatorSlot />;
}); } else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
);
}
return (
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{footer}
</StreamItemWrapper>
);
}, },
[ [
agent.status, getGapBetween,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent, renderStreamItemContent,
agent.status,
streamRenderStrategy, streamRenderStrategy,
inlineWorkingIndicatorItemId,
], ],
); );
@@ -710,56 +604,66 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId], [pendingPermissions, agentId],
); );
const showRunningTurnFooter = agent.status === "running"; const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const pendingPermissionsNode = useMemo( const pendingPermissionsNode = useMemo(
() => () =>
renderPendingPermissionsNode({ pendingPermissionItems.length > 0 ? (
pendingPermissions: pendingPermissionItems, <View style={stylesheet.permissionsContainer}>
client, {pendingPermissionItems.map((permission) => (
}), <PermissionRequestCard key={permission.key} permission={permission} client={client} />
))}
</View>
) : null,
[client, pendingPermissionItems], [client, pendingPermissionItems],
); );
const turnFooterNode = useMemo( const workingIndicatorNode = useMemo(
() => () =>
showRunningTurnFooter || bottomTurnFooterHost ? ( showAuxiliaryWorkingIndicator ? (
<TurnFooter <View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
isRunning={showRunningTurnFooter} <WorkingIndicator />
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt} </View>
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
/>
) : null, ) : null,
[ [showAuxiliaryWorkingIndicator],
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
streamRenderStrategy,
],
); );
const renderModel = useMemo<AgentStreamRenderModel>(() => { const renderModel = useMemo<AgentStreamRenderModel>(() => {
return { return {
...baseRenderModel, ...baseRenderModel,
boundary: { boundary: {
...baseRenderModel.boundary, ...baseRenderModel.boundary,
historyToHeadGap: getGapBetweenStreamItems( historyToHeadGap: getGapBetween(
baseRenderModel.history.at(-1) ?? null, baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null, baseRenderModel.segments.liveHead[0] ?? null,
), ),
}, },
auxiliary: { auxiliary: {
pendingPermissions: pendingPermissionsNode, pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode, workingIndicator: workingIndicatorNode,
}, },
}; };
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]); }, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo( const listEmptyComponent = useMemo(() => {
() => renderListEmptyComponent({ renderModel, emptyStateStyle }), if (
[renderModel, emptyStateStyle], renderModel.boundary.hasVirtualizedHistory ||
); renderModel.boundary.hasMountedHistory ||
renderModel.boundary.hasLiveHead ||
renderModel.auxiliary.pendingPermissions ||
renderModel.auxiliary.workingIndicator
) {
return null;
}
return (
<View style={emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}, [renderModel, emptyStateStyle]);
const historyItems = renderModel.history; const historyItems = renderModel.history;
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel; const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null; const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null; const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
@@ -773,24 +677,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [historyItems]); }, [historyItems]);
const renderHistoryRow = useCallback( const renderHistoryRow = useCallback(
(item: StreamItem) => (item: StreamItem) => {
renderHistoryStreamItem({ const historyIndex = historyIndexById.get(item.id);
item, if (historyIndex === undefined) {
historyIndexById, return null;
historyItems, }
lastHistoryItem, const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
firstLiveHeadItem, return renderStreamItem(item, historyIndex, historyItems, {
bottomTurnFooterHost, belowItem: seamBelowItem,
renderStreamItem, });
}), },
[ [firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
); );
const renderHistoryVirtualizedRow = useCallback< const renderHistoryVirtualizedRow = useCallback<
@@ -802,22 +699,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>( const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) => (item, index, items) =>
renderLiveHeadStreamItem({ renderStreamItem(item, index, items, {
item, aboveItem: index === 0 ? lastHistoryItem : null,
index,
items,
lastHistoryItem,
bottomTurnFooterHost,
renderStreamItem,
}), }),
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem], [lastHistoryItem, renderStreamItem],
); );
const liveAuxiliaryHeaderStyle = useMemo(() => {
let headerPadding: { paddingBottom: number } | { paddingTop: number } | null;
if (!boundary.hasLiveHead) headerPadding = null;
else if (streamRenderStrategy.getFlatListInverted())
headerPadding = { paddingBottom: looseGap };
else headerPadding = { paddingTop: looseGap };
return [stylesheet.listHeaderContent, headerPadding];
}, [boundary.hasLiveHead, streamRenderStrategy, looseGap]);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => { const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
return renderLiveAuxiliaryNode({ if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
pendingPermissions: auxiliary.pendingPermissions, return null;
turnFooter: auxiliary.turnFooter, }
}); return (
}, [auxiliary.pendingPermissions, auxiliary.turnFooter]); <View style={stylesheet.contentWrapper}>
<View style={liveAuxiliaryHeaderStyle}>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
const renderers = useMemo<StreamSegmentRenderers>( const renderers = useMemo<StreamSegmentRenderers>(
() => ({ () => ({
@@ -889,6 +796,106 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent); export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView"; AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={containerStyle}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
</View>
);
}
function InlineWorkingIndicatorSlot() {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
}
interface ToolCallSlotProps extends Omit< interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>, ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange" "onInlineDetailsExpandedChange"
@@ -1185,7 +1192,6 @@ const stylesheet = StyleSheet.create((theme) => ({
width: "100%", width: "100%",
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center", alignSelf: "center",
paddingHorizontal: theme.spacing[2],
}, },
listContentContainer: { listContentContainer: {
paddingVertical: 0, paddingVertical: 0,
@@ -1206,7 +1212,6 @@ const stylesheet = StyleSheet.create((theme) => ({
width: "100%", width: "100%",
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center", alignSelf: "center",
paddingHorizontal: theme.spacing[2],
}, },
emptyState: { emptyState: {
flex: 1, flex: 1,
@@ -1220,6 +1225,51 @@ const stylesheet = StyleSheet.create((theme) => ({
listHeaderContent: { listHeaderContent: {
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
bottomBarWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
marginTop: theme.spacing[4],
paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
inlineTurnFooter: {
alignSelf: "flex-start",
marginTop: theme.spacing[2],
padding: theme.spacing[2],
paddingTop: 0,
},
inlineWorkingIndicatorFrame: {
height: 18,
alignItems: "center",
justifyContent: "center",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: 0,
paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[2],
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
borderWidth: 0,
alignSelf: "flex-start",
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.foregroundMuted,
},
syncingIndicator: { syncingIndicator: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -3,7 +3,6 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"
import { import {
buildModelRows, buildModelRows,
buildSelectedTriggerLabel, buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch, matchesSearch,
resolveProviderLabel, resolveProviderLabel,
} from "./combined-model-selector.utils"; } from "./combined-model-selector.utils";
@@ -84,34 +83,6 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(row, "kimi gemini")).toBe(false); expect(matchesSearch(row, "kimi gemini")).toBe(false);
}); });
it("ranks model search results by fuzzy match quality", () => {
const rows = [
{
favoriteKey: "openai:gpt-4.1",
provider: "openai",
providerLabel: "OpenAI",
modelId: "gpt-4.1",
modelLabel: "GPT-4.1",
},
{
favoriteKey: "openai:gpt-5.4",
provider: "openai",
providerLabel: "OpenAI",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
},
{
favoriteKey: "google:gemini",
provider: "google",
providerLabel: "Google",
modelId: "gemini",
modelLabel: "Gemini",
},
];
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
});
it("keeps the selected trigger label model-only", () => { it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
@@ -1,20 +1,21 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Ref } from "react";
import { import {
View, View,
Text, Text,
TextInput,
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; import { isWeb as platformIsWeb } from "@/constants/platform";
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
const IS_WEB = platformIsWeb; const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -44,11 +45,19 @@ function drillDownRowStyle({
pressed && styles.drillDownRowPressed, pressed && styles.drillDownRowPressed,
]; ];
} }
function backButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.backButton,
Boolean(hovered) && styles.backButtonHovered,
pressed && styles.backButtonPressed,
];
}
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { import {
buildModelRows, buildModelRows,
buildSelectedTriggerLabel, buildSelectedTriggerLabel,
filterAndRankModelRows, matchesSearch,
resolveProviderLabel, resolveProviderLabel,
type SelectorModelRow, type SelectorModelRow,
} from "./combined-model-selector.utils"; } from "./combined-model-selector.utils";
@@ -88,6 +97,7 @@ interface SelectorContentProps {
selectedProvider: string; selectedProvider: string;
selectedModel: string; selectedModel: string;
searchQuery: string; searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>; favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void; onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean; canSelectProvider: (provider: string) => boolean;
@@ -106,6 +116,24 @@ function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase(); 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( function sortFavoritesFirst(
rows: SelectorModelRow[], rows: SelectorModelRow[],
favoriteKeys: Set<string>, favoriteKeys: Set<string>,
@@ -347,23 +375,55 @@ function GroupProviderButton({
function GroupedProviderRows({ function GroupedProviderRows({
groupedRows, groupedRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown, onDrillDown,
viewKind,
}: { }: {
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
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; onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) { }) {
return ( return (
<View> <View>
{groupedRows.map((group, index) => { {groupedRows.map((group, index) => {
const isInline = viewKind === "provider";
return ( return (
<View key={group.providerId}> <View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null} {index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton {isInline ? (
providerId={group.providerId} <>
providerLabel={group.providerLabel} {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
rowCount={group.rows.length} <SelectableModelRow
onDrillDown={onDrillDown} key={row.favoriteKey}
/> row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<GroupProviderButton
providerId={group.providerId}
providerLabel={group.providerLabel}
rowCount={group.rows.length}
onDrillDown={onDrillDown}
/>
)}
</View> </View>
); );
})} })}
@@ -371,65 +431,47 @@ function GroupedProviderRows({
); );
} }
function ProviderModelRows({ function ProviderSearchInput({
rows, value,
selectedProvider, onChangeText,
selectedModel, autoFocus = false,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
normalizedQuery,
}: { }: {
rows: SelectorModelRow[]; value: string;
selectedProvider: string; onChangeText: (text: string) => void;
selectedModel: string; autoFocus?: boolean;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
normalizedQuery: string;
}) { }) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const useVirtualizedList = isMobile && isNative; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
const displayRows = useMemo(
() => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)),
[favoriteKeys, normalizedQuery, rows],
);
const renderItem = useCallback(
({ item }: { item: SelectorModelRow }) => (
<SelectableModelRow
row={item}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
isFavorite={favoriteKeys.has(item.favoriteKey)}
disabled={!canSelectProvider(item.provider)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
),
[canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
if (useVirtualizedList) { useEffect(() => {
return ( if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
<BottomSheetFlatList const timer = setTimeout(() => {
data={displayRows} inputRef.current?.focus();
renderItem={renderItem} }, 50);
keyExtractor={keyExtractor} return () => clearTimeout(timer);
style={styles.virtualizedModelList} }, [autoFocus]);
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} const inputStyle = useMemo(
contentContainerStyle={styles.virtualizedModelListContent} () => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }],
/> [],
); );
}
return ( return (
<View> <View style={styles.providerSearchContainer}>
{displayRows.map((row) => ( <Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<View key={row.favoriteKey}>{renderItem({ item: row })}</View> <InputComponent
))} ref={inputRef as unknown as Ref<never>}
// @ts-expect-error - outlineStyle is web-only
style={inputStyle}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
/>
</View> </View>
); );
} }
@@ -441,6 +483,7 @@ function SelectorContent({
selectedProvider, selectedProvider,
selectedModel, selectedModel,
searchQuery, searchQuery,
onSearchChange: _onSearchChange,
favoriteKeys, favoriteKeys,
onSelect, onSelect,
canSelectProvider, canSelectProvider,
@@ -463,64 +506,95 @@ function SelectorContent({
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const visibleRows = useMemo( const visibleRows = useMemo(
() => filterAndRankModelRows(scopedRows, normalizedQuery), () => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
[normalizedQuery, scopedRows], [normalizedQuery, scopedRows],
); );
const favoriteRows = useMemo( const { favoriteRows, regularRows: _regularRows } = useMemo(
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)), () => partitionRows(visibleRows, favoriteKeys),
[favoriteKeys, visibleRows], [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]); const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
const emptyState = (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
);
if (view.kind === "provider") { // When searching at Level 1, filter grouped rows to only providers whose name or models match
if (visibleRows.length === 0) { const filteredGroupedRows = useMemo(() => {
return emptyState; if (view.kind === "provider" || !normalizedQuery) {
return allGroupedRows;
} }
return allGroupedRows.filter(
return ( (group) =>
<ProviderModelRows group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
rows={visibleRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
normalizedQuery={normalizedQuery}
/>
); );
} }, [allGroupedRows, normalizedQuery, view.kind]);
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
return ( return (
<View> <View>
<FavoritesSection {view.kind === "all" ? (
favoriteRows={favoriteRows} <FavoritesSection
selectedProvider={selectedProvider} favoriteRows={favoriteRows}
selectedModel={selectedModel} selectedProvider={selectedProvider}
favoriteKeys={favoriteKeys} selectedModel={selectedModel}
onSelect={onSelect} favoriteKeys={favoriteKeys}
canSelectProvider={canSelectProvider} onSelect={onSelect}
onToggleFavorite={onToggleFavorite} canSelectProvider={canSelectProvider}
/> onToggleFavorite={onToggleFavorite}
/>
{allGroupedRows.length > 0 ? (
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
) : null} ) : null}
{!hasResults ? emptyState : null} {filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
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> </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={backButtonStyle}>
<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>
);
}
export function CombinedModelSelector({ export function CombinedModelSelector({
providerDefinitions, providerDefinitions,
allProviderModels, allProviderModels,
@@ -542,7 +616,6 @@ export function CombinedModelSelector({
const [isContentReady, setIsContentReady] = useState(platformIsWeb); const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
// Single-provider mode: only one provider with models → skip Level 1 entirely // Single-provider mode: only one provider with models → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => { const singleProviderView = useMemo<SelectorView | null>(() => {
@@ -573,7 +646,6 @@ export function CombinedModelSelector({
onOpen?.(); onOpen?.();
} else { } else {
setSearchQuery(""); setSearchQuery("");
bumpSearchResetKey();
onClose?.(); onClose?.();
} }
}, },
@@ -585,7 +657,6 @@ export function CombinedModelSelector({
onSelect(provider, modelId); onSelect(provider, modelId);
setIsOpen(false); setIsOpen(false);
setSearchQuery(""); setSearchQuery("");
bumpSearchResetKey();
}, },
[onSelect], [onSelect],
); );
@@ -660,48 +731,32 @@ export function CombinedModelSelector({
const handleBackToAll = useCallback(() => { const handleBackToAll = useCallback(() => {
setView({ kind: "all" }); setView({ kind: "all" });
setSearchQuery(""); setSearchQuery("");
bumpSearchResetKey();
}, []); }, []);
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => { const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
setView({ kind: "provider", providerId, providerLabel }); setView({ kind: "provider", providerId, providerLabel });
}, []); }, []);
const handleSearchQueryChange = useCallback((value: string) => { const stickyHeader = useMemo(
setSearchQuery(value); () =>
}, []); view.kind === "provider" ? (
<View style={styles.level2Header}>
const sheetHeader = useMemo<SheetHeader>(() => { {!singleProviderView ? (
if (view.kind === "all") { <ProviderBackButton
return { title: "Select provider" }; providerId={view.providerId}
} providerLabel={view.providerLabel}
const ProviderIconForView = getProviderIcon(view.providerId); onBack={handleBackToAll}
return { />
title: view.providerLabel, ) : null}
leading: ProviderIconForView ? ( <ProviderSearchInput
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} /> value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={platformIsWeb}
/>
</View>
) : undefined, ) : undefined,
back: singleProviderView ? undefined : { onPress: handleBackToAll }, [view, singleProviderView, handleBackToAll, searchQuery],
search: { );
value: searchQuery,
onChange: handleSearchQueryChange,
initialValue: searchQuery,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...",
autoFocus: platformIsWeb,
testID: "model-search-input",
},
};
}, [
view,
singleProviderView,
handleBackToAll,
handleSearchQueryChange,
searchQuery,
searchResetKey,
theme.iconSize.md,
theme.colors.foreground,
]);
return ( return (
<> <>
@@ -744,8 +799,8 @@ export function CombinedModelSelector({
desktopPlacement="top-start" desktopPlacement="top-start"
desktopMinWidth={360} desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight} desktopFixedHeight={desktopFixedHeight}
header={sheetHeader} title="Select model"
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative} stickyHeader={stickyHeader}
> >
{isContentReady ? ( {isContentReady ? (
<SelectorContent <SelectorContent
@@ -755,6 +810,7 @@ export function CombinedModelSelector({
selectedProvider={selectedProvider} selectedProvider={selectedProvider}
selectedModel={selectedModel} selectedModel={selectedModel}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onSelect={handleSelect} onSelect={handleSelect}
canSelectProvider={canSelectProvider} canSelectProvider={canSelectProvider}
@@ -857,6 +913,27 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
level2Header: {},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
backButtonHovered: {
backgroundColor: theme.colors.surface2,
},
backButtonPressed: {
backgroundColor: theme.colors.surface2,
},
backButtonText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyState: { emptyState: {
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
alignItems: "center", alignItems: "center",
@@ -866,14 +943,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
virtualizedModelList: {
flex: 1,
},
virtualizedModelListContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[8],
},
favoriteButton: { favoriteButton: {
width: 24, width: 24,
height: 24, height: 24,
@@ -897,4 +966,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, 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,7 +1,6 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type SelectorModelRow = FavoriteModelRow; export type SelectorModelRow = FavoriteModelRow;
@@ -45,33 +44,14 @@ export function buildModelRows(
} }
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean { export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
return scoreModelRow(row, normalizedQuery) !== null; if (!normalizedQuery) {
} return true;
}
function getModelRowSearchFields(row: SelectorModelRow): string[] { const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]; .join(" ")
} .toLowerCase();
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) { const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row)); return tokens.every((token) => haystack.includes(token));
}
export function filterAndRankModelRows(
rows: SelectorModelRow[],
normalizedQuery: string,
): SelectorModelRow[] {
if (!normalizedQuery) return rows;
const scored = rows
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
Boolean(entry.score),
);
scored.sort((a, b) => {
const cmp = compareMatchScores(a.score, b.score);
if (cmp !== 0) return cmp;
return a.row.modelLabel.localeCompare(b.row.modelLabel);
});
return scored.map((entry) => entry.row);
} }
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages"; import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { import type {
AttachmentMetadata, AttachmentMetadata,
@@ -19,7 +19,6 @@ import {
removeComposerAttachmentAtIndex, removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow, sendQueuedComposerMessageNow,
toggleGithubAttachment, toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter, type AgentStreamWriter,
type AttachmentPersister, type AttachmentPersister,
type ComposerCancelClient, type ComposerCancelClient,
@@ -697,36 +696,6 @@ describe("toggleGithubAttachment", () => {
}); });
}); });
describe("toggleGithubAttachmentFromPicker", () => {
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
const markGithubAttachmentRemoved = vi.fn();
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
const next = toggleGithubAttachmentFromPicker({
current: [attachment],
item: prItem,
markGithubAttachmentRemoved,
});
expect(next).toEqual([]);
expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1);
expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment);
});
it("does not mark a GitHub item removed when picker toggle adds it", () => {
const markGithubAttachmentRemoved = vi.fn();
const next = toggleGithubAttachmentFromPicker({
current: [],
item: issueItem,
markGithubAttachmentRemoved,
});
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
});
});
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => { describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => { it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem); expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
@@ -305,29 +305,6 @@ export function toggleGithubAttachment(
return [...current, buildGithubAttachment(item)]; return [...current, buildGithubAttachment(item)];
} }
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
export function toggleGithubAttachmentFromPicker({
current,
item,
markGithubAttachmentRemoved,
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
}
export function findGithubItemByOption( export function findGithubItemByOption(
items: readonly GitHubSearchItem[], items: readonly GitHubSearchItem[],
optionId: string, optionId: string,
@@ -98,19 +98,13 @@ export function useComposerHeightMirror({
useLayoutEffect(() => { useLayoutEffect(() => {
measure(); measure();
}, [maxHeight, minHeight, value, measure]); }, [value, measure]);
useEffect(() => { useEffect(() => {
const source = textareaRef.current; const source = textareaRef.current;
if (!source || !(source instanceof HTMLElement)) return; if (!source || !(source instanceof HTMLElement)) return;
if (typeof ResizeObserver === "undefined") return; if (typeof ResizeObserver === "undefined") return;
let previousWidth = source.clientWidth; const observer = new ResizeObserver(() => measure());
const observer = new ResizeObserver(() => {
const nextWidth = source.clientWidth;
if (Math.abs(nextWidth - previousWidth) < 1) return;
previousWidth = nextWidth;
measure();
});
observer.observe(source); observer.observe(source);
return () => observer.disconnect(); return () => observer.disconnect();
}, [textareaRef, measure]); }, [textareaRef, measure]);
+52 -126
View File
@@ -21,6 +21,7 @@ import {
Paperclip, Paperclip,
} from "lucide-react-native"; } from "lucide-react-native";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { import {
AgentStatusBar, AgentStatusBar,
@@ -52,7 +53,7 @@ import {
queueComposerMessage, queueComposerMessage,
removeComposerAttachmentAtIndex, removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow, sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker, toggleGithubAttachment,
type AgentStreamWriter, type AgentStreamWriter,
type QueueWriter, type QueueWriter,
type QueuedComposerMessage, type QueuedComposerMessage,
@@ -96,10 +97,6 @@ import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
type QueuedMessage = QueuedComposerMessage; type QueuedMessage = QueuedComposerMessage;
@@ -140,20 +137,6 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
} }
function resolveGithubSearchEnabled(
isGithubPickerOpen: boolean,
isConnected: boolean,
cwd: string,
): boolean {
return isGithubPickerOpen && isConnected && cwd.trim().length > 0;
}
function resolveCheckoutRemoteUrl(
checkoutStatus: ReturnType<typeof useCheckoutStatusQuery>["status"],
): string | null {
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
@@ -181,6 +164,31 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
}; };
} }
interface BuildGithubSearchQueryOptionsArgs {
serverId: string;
cwd: string;
githubSearchQueryTrimmed: string;
isGithubPickerOpen: boolean;
isConnected: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
}
function buildGithubSearchQueryOptions(args: BuildGithubSearchQueryOptionsArgs) {
const { serverId, cwd, githubSearchQueryTrimmed, isGithubPickerOpen, isConnected, client } = args;
const hasClient = Boolean(client);
const cwdIsSet = cwd.trim().length > 0;
const enabled = isGithubPickerOpen && isConnected && hasClient && cwdIsSet;
return {
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
queryFn: async () => {
if (!client) throw new Error("Host is not connected");
return client.searchGitHub({ cwd, query: githubSearchQueryTrimmed, limit: 20 });
},
enabled,
staleTime: 30_000,
};
}
function renderContextWindowMeterSlot( function renderContextWindowMeterSlot(
contextWindowMaxTokens: number | null, contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null, contextWindowUsedTokens: number | null,
@@ -608,7 +616,6 @@ interface ComposerProps {
serverId: string; serverId: string;
isPaneFocused: boolean; isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>; onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean; hasExternalContent?: boolean;
/** When true, the composer can submit even with no text or attachments. */ /** When true, the composer can submit even with no text or attachments. */
@@ -741,7 +748,6 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isAgentRunning: boolean; isAgentRunning: boolean;
hasSendableContent: boolean; hasSendableContent: boolean;
isProcessing: boolean; isProcessing: boolean;
isCompact: boolean;
cancelButton: ReactElement; cancelButton: ReactElement;
} }
@@ -751,12 +757,10 @@ function ComposerRightControlsSlot({
isAgentRunning, isAgentRunning,
hasSendableContent, hasSendableContent,
isProcessing, isProcessing,
isCompact,
cancelButton, cancelButton,
...voiceProps ...voiceProps
}: ComposerRightControlsSlotProps) { }: ComposerRightControlsSlotProps) {
const hideVoiceForCompactInput = isCompact && hasSendableContent; const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing; const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
if (!showVoiceModeButton && !shouldShowCancelButton) return null; if (!showVoiceModeButton && !shouldShowCancelButton) return null;
return ( return (
@@ -812,7 +816,6 @@ export function Composer({
serverId, serverId,
isPaneFocused, isPaneFocused,
onSubmitMessage, onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false, allowEmptySubmit = false,
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
@@ -887,17 +890,6 @@ export function Composer({
onOpenWorkspaceAttachment, onOpenWorkspaceAttachment,
}); });
const setSelectedAttachments = onChangeAttachments; const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client,
isConnected,
serverId,
cwd,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0); const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [isCancellingAgent, setIsCancellingAgent] = useState(false);
@@ -913,41 +905,6 @@ export function Composer({
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
); );
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
return false;
}
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
clearDraft("sent");
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
setIsProcessing(true);
void onClientSlashCommand(command)
.catch((error) => {
console.error("[Composer] Failed to run client slash command:", error);
setSendError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
setIsProcessing(false);
});
return true;
},
[
blurOnSubmit,
clearDraft,
onClientSlashCommand,
resetSuppression,
setSelectedAttachments,
setUserInput,
],
);
const autocomplete = useAgentAutocomplete({ const autocomplete = useAgentAutocomplete({
userInput, userInput,
cursorIndex, cursorIndex,
@@ -955,8 +912,6 @@ export function Composer({
serverId, serverId,
agentId, agentId,
draftConfig: commandDraftConfig, draftConfig: commandDraftConfig,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
onAutocompleteApplied: () => { onAutocompleteApplied: () => {
messageInputRef.current?.focus(); messageInputRef.current?.focus();
}, },
@@ -1152,27 +1107,16 @@ export function Composer({
const handleSubmit = useCallback( const handleSubmit = useCallback(
(payload: MessagePayload) => { (payload: MessagePayload) => {
const outgoingAttachments = buildOutgoingAttachments(attachments);
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
if (blurOnSubmit) { if (blurOnSubmit) {
messageInputRef.current?.blur(); messageInputRef.current?.blur();
} }
void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend); void sendMessageWithContent(
payload.text,
buildOutgoingAttachments(attachments),
payload.forceSend,
);
}, },
[ [attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent],
attachments,
blurOnSubmit,
buildOutgoingAttachments,
runClientSlashCommand,
sendMessageWithContent,
],
); );
const handlePickImage = useCallback(async () => { const handlePickImage = useCallback(async () => {
@@ -1191,7 +1135,6 @@ export function Composer({
const handleRemoveAttachment = useCallback( const handleRemoveAttachment = useCallback(
(index: number) => { (index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
const didRemoveWorkspaceAttachment = removeAttachment({ const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments, selectedAttachments,
index, index,
@@ -1203,7 +1146,7 @@ export function Composer({
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
); );
}, },
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], [removeAttachment, selectedAttachments, setSelectedAttachments],
); );
const handleOpenAttachment = useCallback( const handleOpenAttachment = useCallback(
@@ -1334,25 +1277,18 @@ export function Composer({
const handleQueue = useCallback( const handleQueue = useCallback(
(payload: MessagePayload) => { (payload: MessagePayload) => {
const outgoingAttachments = buildOutgoingAttachments(attachments); queueMessage(payload.text, buildOutgoingAttachments(attachments));
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
queueMessage(payload.text, outgoingAttachments);
}, },
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand], [attachments, buildOutgoingAttachments, queueMessage],
); );
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete. // Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback( const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => (event: { key: string; preventDefault: () => void }) => {
autocompleteOnKeyPressRef.current(event), return autocompleteOnKeyPressRef.current(event);
},
[], [],
); );
@@ -1404,7 +1340,6 @@ export function Composer({
isAgentRunning={isAgentRunning} isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent} hasSendableContent={hasSendableContent}
isProcessing={isProcessing} isProcessing={isProcessing}
isCompact={isMobile}
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice} handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected} isConnected={isConnected}
@@ -1422,7 +1357,6 @@ export function Composer({
hasSendableContent, hasSendableContent,
isAgentRunning, isAgentRunning,
isConnected, isConnected,
isMobile,
isProcessing, isProcessing,
isVoiceModeForAgent, isVoiceModeForAgent,
isVoiceSwitching, isVoiceSwitching,
@@ -1442,13 +1376,16 @@ export function Composer({
); );
const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useGithubSearchQuery({ const githubSearchResultsQuery = useQuery(
client, buildGithubSearchQueryOptions({
serverId, serverId,
cwd, cwd,
query: githubSearchQueryTrimmed, githubSearchQueryTrimmed,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), isGithubPickerOpen,
}); isConnected,
client,
}),
);
const githubSearchItemsRaw = githubSearchResultsQuery.data?.items; const githubSearchItemsRaw = githubSearchResultsQuery.data?.items;
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]); const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
@@ -1486,22 +1423,11 @@ export function Composer({
const handleToggleGithubItem = useCallback( const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => { (item: GitHubSearchItem) => {
const nextAttachments = toggleGithubAttachmentFromPicker({ setSelectedAttachments((current) => toggleGithubAttachment(current, item));
current: attachments,
item,
markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved,
});
setSelectedAttachments(nextAttachments);
setIsGithubPickerOpen(false); setIsGithubPickerOpen(false);
setGithubSearchQuery(""); setGithubSearchQuery("");
}, },
[ [setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
attachments,
githubAutoAttach,
setSelectedAttachments,
setGithubSearchQuery,
setIsGithubPickerOpen,
],
); );
const leftContent = useMemo( const leftContent = useMemo(
@@ -13,9 +13,6 @@ interface DndContextProps {
} }
let latestDndContextProps: DndContextProps | null = null; let latestDndContextProps: DndContextProps | null = null;
const dndKitMocks = vi.hoisted(() => ({
useSensor: vi.fn(() => ({})),
}));
vi.mock("@dnd-kit/core", () => ({ vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => { DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
@@ -25,7 +22,7 @@ vi.mock("@dnd-kit/core", () => ({
closestCenter: vi.fn(), closestCenter: vi.fn(),
KeyboardSensor: vi.fn(), KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(), PointerSensor: vi.fn(),
useSensor: dndKitMocks.useSensor, useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []), useSensors: vi.fn(() => []),
})); }));
@@ -107,7 +104,7 @@ function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
); );
} }
function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void { function renderList(): void {
act(() => { act(() => {
root?.render( root?.render(
<DraggableList <DraggableList
@@ -116,7 +113,6 @@ function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {})
onDragEnd={vi.fn()} onDragEnd={vi.fn()}
renderItem={renderItem} renderItem={renderItem}
scrollEnabled={false} scrollEnabled={false}
useDragHandle={useDragHandle}
/>, />,
); );
}); });
@@ -129,28 +125,6 @@ function getItemActiveState(item: string): string | null {
} }
describe("DraggableList web", () => { describe("DraggableList web", () => {
it("uses distance activation for default draggable rows", () => {
renderList();
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
activationConstraint: { distance: 6 },
}),
);
});
it("requires a held pointer before activating handle-based drags", () => {
renderList({ useDragHandle: true });
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
activationConstraint: { delay: 250, tolerance: 8 },
}),
);
});
it("clears active drag state when a drag is cancelled", () => { it("clears active drag state when a drag is cancelled", () => {
renderList(); renderList();
@@ -30,8 +30,6 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
}); });
const DND_MODIFIERS = [restrictToVerticalAxis]; const DND_MODIFIERS = [restrictToVerticalAxis];
const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 };
const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 };
interface SortableItemProps<T> { interface SortableItemProps<T> {
id: string; id: string;
@@ -145,13 +143,12 @@ export function DraggableList<T>({
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar, enabled: showCustomScrollbar,
}); });
const pointerActivationConstraint = useDragHandle
? HANDLE_POINTER_ACTIVATION_CONSTRAINT
: DEFAULT_POINTER_ACTIVATION_CONSTRAINT;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: pointerActivationConstraint, activationConstraint: {
distance: 6,
},
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
+39 -77
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from "react"; import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@server/client/daemon-client"; import type { FileReadResult } from "@server/client/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display"; import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -15,8 +15,13 @@ import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import {
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; highlightCode,
darkHighlightColors,
lightHighlightColors,
type HighlightToken,
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -26,13 +31,13 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes } from "@/attachments/service"; import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
lineNumber: number; lineNumber: number;
gutterWidth: number; gutterWidth: number;
highlighted: boolean; colorMap: Record<HighlightStyle, string>;
baseColor: string;
} }
interface FilePreviewBodyProps { interface FilePreviewBodyProps {
@@ -40,7 +45,7 @@ interface FilePreviewBodyProps {
isLoading: boolean; isLoading: boolean;
showDesktopWebScrollbar: boolean; showDesktopWebScrollbar: boolean;
isMobile: boolean; isMobile: boolean;
location: WorkspaceFileLocation; filePath: string;
imagePreviewUri: string | null; imagePreviewUri: string | null;
} }
@@ -52,11 +57,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
return `${size} B`; return `${size} B`;
@@ -99,46 +99,36 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{
}; };
} }
function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
lineCount: number;
}): FileLineSelection | null {
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
return null;
}
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
const rawLineEnd =
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
const CodeLine = React.memo(function CodeLine({ const CodeLine = React.memo(function CodeLine({
tokens, tokens,
lineNumber, lineNumber,
gutterWidth, gutterWidth,
highlighted, colorMap,
baseColor,
}: CodeLineProps) { }: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]); const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const lineStyle = useMemo( const gutterTextStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine], () => [codeLineStyles.gutterText, { color: baseColor }],
[highlighted], [baseColor],
); );
const keyedTokens = useMemo( const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens], [tokens],
); );
return ( return (
<View style={lineStyle}> <View style={codeLineStyles.line}>
<View style={gutterStyle}> <View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}> <Text numberOfLines={1} style={gutterTextStyle}>
{String(lineNumber)} {String(lineNumber)}
</Text> </Text>
</View> </View>
<Text selectable style={codeLineStyles.lineText}> <Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => ( {keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} /> <CodeLineToken
key={key}
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
text={token.text}
/>
))} ))}
</Text> </Text>
</View> </View>
@@ -146,27 +136,25 @@ const CodeLine = React.memo(function CodeLine({
}); });
interface CodeLineTokenProps { interface CodeLineTokenProps {
token: HighlightToken; color: string;
text: string;
} }
function CodeLineToken({ token }: CodeLineTokenProps) { function CodeLineToken({ color, text }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>; const style = useMemo(() => ({ color }), [color]);
return <Text style={style}>{text}</Text>;
} }
const codeLineStyles = StyleSheet.create((theme) => ({ const codeLineStyles = StyleSheet.create((theme) => ({
line: { line: {
flexDirection: "row", flexDirection: "row",
}, },
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: { gutter: {
alignItems: "flex-end", alignItems: "flex-end",
paddingRight: theme.spacing[3], paddingRight: theme.spacing[3],
flexShrink: 0, flexShrink: 0,
}, },
gutterText: { gutterText: {
color: theme.colors.foreground,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45, lineHeight: theme.fontSize.sm * 1.45,
@@ -186,15 +174,16 @@ function FilePreviewBody({
isLoading, isLoading,
showDesktopWebScrollbar, showDesktopWebScrollbar,
isMobile, isMobile,
location, filePath,
imagePreviewUri, imagePreviewUri,
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const filePath = location.path; const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null); const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle(); const webScrollbarStyle = useWebScrollbarStyle();
@@ -214,36 +203,12 @@ function FilePreviewBody({
if (!highlightedLines) return 0; if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm); return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
}, [highlightedLines, theme.fontSize.sm]); }, [highlightedLines, theme.fontSize.sm]);
const lineHeight = theme.fontSize.sm * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
const imageSource = useMemo( const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null), () => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri], [imagePreviewUri],
); );
useEffect(() => {
if (!lineSelection) {
return;
}
const timeout = setTimeout(() => {
previewScrollRef.current?.scrollTo({
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
animated: false,
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
if (isLoading && !preview) { if (isLoading && !preview) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -298,11 +263,8 @@ function FilePreviewBody({
tokens={tokens} tokens={tokens}
lineNumber={lineNumber} lineNumber={lineNumber}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
highlighted={ colorMap={colorMap}
Boolean(lineSelection) && baseColor={baseColor}
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/> />
))} ))}
</View> </View>
@@ -382,18 +344,18 @@ function FilePreviewBody({
export function FilePane({ export function FilePane({
serverId, serverId,
workspaceRoot, workspaceRoot,
location, filePath,
}: { }: {
serverId: string; serverId: string;
workspaceRoot: string; workspaceRoot: string;
location: WorkspaceFileLocation; filePath: string;
}) { }) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const query = useQuery({ const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath], queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
@@ -436,7 +398,7 @@ export function FilePane({
isLoading={query.isFetching} isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar} showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile} isMobile={isMobile}
location={location} filePath={filePath}
imagePreviewUri={imagePreviewUri} imagePreviewUri={imagePreviewUri}
/> />
</View> </View>
@@ -1,269 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
interface HighlightedCodeBlockProps {
code: string;
language: string | null | undefined;
inheritedStyles: TextStyle;
textStyle: TextStyle;
}
// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the
// extension-based parser table in @getpaseo/highlight. Aliases here only
// cover names that don't already match an extension key in parsers.ts.
const LANGUAGE_ALIASES: Record<string, string> = {
typescript: "ts",
javascript: "js",
python: "py",
rust: "rs",
golang: "go",
"c++": "cpp",
objc: "m",
"objective-c": "m",
markdown: "md",
elixir: "ex",
};
function fenceLanguageToExtension(info: string | null | undefined): string | null {
if (!info) return null;
const first = info.trim().split(/\s+/)[0]?.toLowerCase();
if (!first) return null;
const normalized = first.replace(/^\./, "");
return LANGUAGE_ALIASES[normalized] ?? normalized;
}
// Cross-instance cache for tokenized code blocks. Tokenization is
// theme-independent (colors are applied at render time), so the key is just
// (language, code). Bounded by entry count — 200 is generous for a chat
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
class LRUCache<K, V> {
private readonly map = new Map<K, V>();
constructor(private readonly max: number) {}
get(key: K): V | undefined {
const value = this.map.get(key);
if (value === undefined) return undefined;
this.map.delete(key);
this.map.set(key, value);
return value;
}
set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
else if (this.map.size >= this.max) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
this.map.set(key, value);
}
}
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
code,
language,
inheritedStyles,
textStyle,
}: HighlightedCodeBlockProps) {
// Box styles (bg / padding / border / radius / margin) go on the wrapper View
// so the absolute copy button positions relative to the visible code area,
// not to a parent that includes the Text's own marginVertical.
const { containerStyle, innerTextStyle } = useMemo(
() => splitFenceStyle(inheritedStyles, textStyle),
[inheritedStyles, textStyle],
);
const keyedLines = useMemo<KeyedLine[] | null>(() => {
const ext = fenceLanguageToExtension(language);
if (!ext) return null;
const cacheKey = `${ext}:${code}`;
const cached = tokenizationCache.get(cacheKey);
if (cached) return cached;
let tokenizedLines: HighlightToken[][];
try {
tokenizedLines = highlightCode(code, `x.${ext}`);
} catch {
return null;
}
const result = tokenizedLines.map(toKeyedLine);
tokenizationCache.set(cacheKey, result);
return result;
}, [code, language]);
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const controlsVisible = isHovered || isNative || isCompact;
const getCode = useCallback(() => code, [code]);
return (
<View
style={containerStyle}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{keyedLines ? (
<Text style={innerTextStyle}>
{keyedLines.map((line, lineIndex) => (
<React.Fragment key={line.key}>
{lineIndex > 0 ? "\n" : null}
{line.tokens.map(({ key, token }) => (
<TokenSpan key={key} token={token} />
))}
</React.Fragment>
))}
</Text>
) : (
<Text style={innerTextStyle}>{code}</Text>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
);
});
interface KeyedToken {
key: string;
token: HighlightToken;
}
interface KeyedLine {
key: string;
tokens: KeyedToken[];
}
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
return {
key: `line-${lineIndex}`,
tokens: tokens.map((token, tokenIndex) => ({
key: `${lineIndex}-${tokenIndex}`,
token,
})),
};
}
interface TokenSpanProps {
token: HighlightToken;
}
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
if (!token.style) return token.text;
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
});
interface SplitStyles {
containerStyle: StyleProp<ViewStyle>;
innerTextStyle: StyleProp<TextStyle>;
}
const CONTAINER_BASE: ViewStyle = { position: "relative" };
const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {};
function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles {
const { fontFamily, fontSize, color, ...box } = textStyle;
const textOnly: TextStyle = { ...WEB_SELECTABLE };
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
if (fontSize !== undefined) textOnly.fontSize = fontSize;
if (color !== undefined) textOnly.color = color;
return {
containerStyle: [box as ViewStyle, CONTAINER_BASE],
innerTextStyle: [inheritedStyles, textOnly],
};
}
interface CopyButtonProps {
getCode: () => string;
visible: boolean;
}
const COPIED_RESET_MS = 1500;
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (resetRef.current) clearTimeout(resetRef.current);
},
[],
);
const handlePress = useCallback(async () => {
const content = getCode();
if (!content) return;
await Clipboard.setStringAsync(content);
setCopied(true);
if (resetRef.current) clearTimeout(resetRef.current);
resetRef.current = setTimeout(() => {
setCopied(false);
resetRef.current = null;
}, COPIED_RESET_MS);
}, [getCode]);
const visibilityStyle = visible
? copyButtonStyles.containerVisible
: copyButtonStyles.containerHidden;
const wrapperStyle = useMemo(
() => [copyButtonStyles.container, visibilityStyle],
[visibilityStyle],
);
return (
<Pressable
onPress={handlePress}
style={wrapperStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy code"}
hitSlop={8}
>
{({ hovered }) => {
const iconColor = hovered
? copyButtonStyles.iconHoveredColor.color
: copyButtonStyles.iconColor.color;
return copied ? (
<Check size={14} color={iconColor} />
) : (
<Copy size={14} color={iconColor} />
);
}}
</Pressable>
);
});
const copyButtonStyles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
padding: theme.spacing[1],
},
containerVisible: {
opacity: 1,
},
containerHidden: {
opacity: 0,
},
iconColor: {
color: theme.colors.foregroundMuted,
},
iconHoveredColor: {
color: theme.colors.foreground,
},
}));
@@ -2,14 +2,13 @@ import { useCallback, useMemo } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"]; const SNAP_POINTS: string[] = ["70%", "92%"];
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
export function KeyboardShortcutsDialog() { export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
@@ -26,7 +25,7 @@ export function KeyboardShortcutsDialog() {
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={SHORTCUTS_HEADER} title="Shortcuts"
visible={open} visible={open}
onClose={handleClose} onClose={handleClose}
testID="keyboard-shortcuts-dialog" testID="keyboard-shortcuts-dialog"
+4 -39
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { FolderPlus, MessagesSquare, Settings, X } from "lucide-react-native"; import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
import { import {
type Dispatch, type Dispatch,
memo, memo,
@@ -472,7 +472,6 @@ function SidebarFooter({
searchable={false} searchable={false}
title="Switch host" title="Switch host"
searchPlaceholder="Search hosts..." searchPlaceholder="Search hosts..."
desktopMinWidth={280}
open={isHostPickerOpen} open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen} onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef} anchorRef={hostTriggerRef}
@@ -680,25 +679,6 @@ function MobileSidebar({
isActive={isSessionsActive} isActive={isSessionsActive}
testID="sidebar-sessions" testID="sidebar-sessions"
/> />
<Pressable
style={styles.mobileCloseButton}
onPress={closeToAgent}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel="Close sidebar"
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
{isInitialLoad ? ( {isInitialLoad ? (
<SidebarAgentListSkeleton /> <SidebarAgentListSkeleton />
@@ -811,13 +791,10 @@ function DesktopSidebar({
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo( const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle], () => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle], [resizeAnimatedStyle, insetsTop],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
); );
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo( const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[], [],
@@ -908,18 +885,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
}, },
mobileCloseButton: {
position: "absolute",
top: theme.spacing[3],
right: theme.spacing[4],
zIndex: 2,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSidebarBorder: { desktopSidebarBorder: {
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: theme.colors.border, borderRightColor: theme.colors.border,
@@ -1,19 +0,0 @@
import { describe, expect, it } from "vitest";
import { getCompactionMarkerLabel } from "./message-compaction-label";
describe("getCompactionMarkerLabel", () => {
it("renders loading, automatic, manual, tokenized, and fallback labels", () => {
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("Compacting...");
expect(getCompactionMarkerLabel({ status: "completed", trigger: "auto" })).toBe(
"Context automatically compacted",
);
expect(getCompactionMarkerLabel({ status: "completed", trigger: "manual" })).toBe(
"Context manually compacted",
);
expect(getCompactionMarkerLabel({ status: "completed", preTokens: 12_345 })).toBe(
"Context compacted (12K tokens)",
);
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
});
});
@@ -1,17 +0,0 @@
export interface CompactionMarkerLabelInput {
status: "loading" | "completed";
trigger?: "auto" | "manual";
preTokens?: number;
}
export function getCompactionMarkerLabel({
status,
trigger,
preTokens,
}: CompactionMarkerLabelInput): string {
if (status === "loading") return "Compacting...";
if (trigger === "auto") return "Context automatically compacted";
if (trigger === "manual") return "Context manually compacted";
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
return "Context compacted";
}
+18 -42
View File
@@ -3,7 +3,6 @@ import {
Text, Text,
TextInput, TextInput,
ActivityIndicator, ActivityIndicator,
useWindowDimensions,
NativeSyntheticEvent, NativeSyntheticEvent,
TextInputContentSizeChangeEventData, TextInputContentSizeChangeEventData,
TextInputKeyPressEventData, TextInputKeyPressEventData,
@@ -48,13 +47,11 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
import { formatShortcut } from "@/utils/format-shortcut"; import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./composer-height-mirror"; import { useComposerHeightMirror } from "./composer-height-mirror";
import { computeCanStartDictation } from "./message-input-state"; import { computeCanStartDictation } from "./message-input-state";
@@ -145,8 +142,7 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT_MOBILE = 30; const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46; const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
@@ -368,7 +364,6 @@ function resolveSendTooltipLabel(input: {
interface DesktopKeyPressContext { interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
submitOnEnter: boolean;
isAgentRunning: boolean; isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean; isSubmitDisabled: boolean;
@@ -395,7 +390,6 @@ function handleDesktopKeyPressImpl(
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
if (event.nativeEvent.key !== "Enter") return; if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return; if (shiftKey) return;
if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
@@ -985,22 +979,17 @@ function computeIsDictationStartEnabled(
return (isReadyForDictation ?? isConnected) && !disabled; return (isReadyForDictation ?? isConnected) && !disabled;
} }
function resolveMaxInputHeight(windowHeight: number): number { function computeTextInputHeightStyle(inputHeight: number) {
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
if (isWeb) { if (isWeb) {
return { return {
height: inputHeight, height: inputHeight,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight, maxHeight: MAX_INPUT_HEIGHT,
}; };
} }
return { return {
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight, maxHeight: MAX_INPUT_HEIGHT,
}; };
} }
@@ -1197,9 +1186,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
inputWrapperStyle, inputWrapperStyle,
attachmentSlot, attachmentSlot,
} = resolveMessageInputProps(props); } = resolveMessageInputProps(props);
const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const maxInputHeight = resolveMaxInputHeight(windowHeight);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast(); const toast = useToast();
const voice = useVoiceOptional(); const voice = useVoiceOptional();
@@ -1464,7 +1450,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
() => () =>
sendMessageImpl({ sendMessageImpl({
value: valueRef.current, value,
attachments, attachments,
hasExternalContent, hasExternalContent,
allowEmptySubmit, allowEmptySubmit,
@@ -1475,6 +1461,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}), }),
[ [
allowEmptySubmit, allowEmptySubmit,
value,
attachments, attachments,
cwd, cwd,
onSubmit, onSubmit,
@@ -1487,14 +1474,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleQueueMessage = useCallback( const handleQueueMessage = useCallback(
() => () =>
queueMessageImpl({ queueMessageImpl({
value: valueRef.current, value,
attachments, attachments,
cwd, cwd,
onQueue, onQueue,
onChangeText, onChangeText,
onMinimizeHeight: minimizeInputHeight, onMinimizeHeight: minimizeInputHeight,
}), }),
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight], [value, attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
); );
const handleDefaultSendAction = useCallback(() => { const handleDefaultSendAction = useCallback(() => {
@@ -1531,7 +1518,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [getWebTextArea]); }, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, { const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: isWeb, enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
}); });
usePasteImagesEffect({ usePasteImagesEffect({
@@ -1545,24 +1532,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const setBoundedInputHeight = useCallback( const setBoundedInputHeight = useCallback(
(nextHeight: number) => { (nextHeight: number) => {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight)); const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
if (Math.abs(inputHeightRef.current - bounded) < 1) return; if (Math.abs(inputHeightRef.current - bounded) < 1) return;
inputHeightRef.current = bounded; inputHeightRef.current = bounded;
setInputHeight(bounded); setInputHeight(bounded);
onHeightChange?.(bounded); onHeightChange?.(bounded);
}, },
[maxInputHeight, onHeightChange], [onHeightChange],
); );
useEffect(() => {
setBoundedInputHeight(inputHeightRef.current);
}, [setBoundedInputHeight]);
useComposerHeightMirror({ useComposerHeightMirror({
value, value,
textareaRef: webTextareaRef, textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight, maxHeight: MAX_INPUT_HEIGHT,
onHeight: setBoundedInputHeight, onHeight: setBoundedInputHeight,
}); });
@@ -1583,14 +1566,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[onSelectionChangeCallback], [onSelectionChangeCallback],
); );
const shouldHandleWebKeyPress = isWeb; const shouldHandleDesktopSubmit = isWeb;
const shouldSubmitOnEnter = isWeb && !isCompact;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleWebKeyPress) return; if (!shouldHandleDesktopSubmit) return;
handleDesktopKeyPressImpl(event, { handleDesktopKeyPressImpl(event, {
onKeyPressCallback, onKeyPressCallback,
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning, isAgentRunning,
onQueue, onQueue,
isSubmitDisabled, isSubmitDisabled,
@@ -1617,10 +1598,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
defaultSendBehavior, defaultSendBehavior,
isAgentRunning, isAgentRunning,
}); });
useIosHardwareKeyboardSubmit({
isEnabled: isInputFocused && !isSendButtonDisabled,
onSubmit: handleDefaultSendAction,
});
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({ const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
canPressLoadingButton, canPressLoadingButton,
@@ -1641,7 +1618,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
valueRef.current = nextValue;
onChangeText(nextValue); onChangeText(nextValue);
}, },
[onChangeText], [onChangeText],
@@ -1687,8 +1663,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[inputWrapperStyle, inputAnimatedStyle], [inputWrapperStyle, inputAnimatedStyle],
); );
const textInputStyle = useMemo( const textInputStyle = useMemo(
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)], () => [styles.textInput, computeTextInputHeightStyle(inputHeight)],
[inputHeight, maxInputHeight], [inputHeight],
); );
const sendButtonCombinedStyle = useMemo( const sendButtonCombinedStyle = useMemo(
() => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled], () => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled],
@@ -1740,10 +1716,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
style={textInputStyle} style={textInputStyle}
multiline multiline
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
autoFocus={isWeb && autoFocus} autoFocus={isWeb && autoFocus}
/> />
File diff suppressed because it is too large Load Diff
@@ -8,11 +8,10 @@ import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
helper: { helper: {
@@ -170,7 +169,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={PAIR_LINK_HEADER} title="Paste pairing link"
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
testID="pair-link-modal" testID="pair-link-modal"
@@ -1,5 +1,5 @@
import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native"; import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@@ -9,11 +9,7 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -100,7 +96,6 @@ function CustomModelsSection(props: {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId); const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(null); const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
@@ -141,11 +136,7 @@ function CustomModelsSection(props: {
label: trimmedInput, label: trimmedInput,
}, },
]) ])
.then(() => { .then(() => setInput(""))
setInput("");
bumpInputResetKey();
return undefined;
})
.catch((err) => { .catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model"); setError(err instanceof Error ? err.message : "Failed to save model");
}) })
@@ -172,8 +163,6 @@ function CustomModelsSection(props: {
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={INLINE_ROW_STYLE}> <View style={INLINE_ROW_STYLE}>
<AdaptiveTextInput <AdaptiveTextInput
initialValue={input}
resetKey={`custom-model-input-${inputResetKey}`}
value={input} value={input}
onChangeText={setInput} onChangeText={setInput}
onSubmitEditing={handleAdd} onSubmitEditing={handleAdd}
@@ -256,7 +245,6 @@ export function ProviderDiagnosticSheet({
const [diagnostic, setDiagnostic] = useState<string | null>(null); const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0);
const providerLabel = resolveProviderLabel(provider, snapshotEntries); const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo( const providerEntry = useMemo(
@@ -360,7 +348,6 @@ export function ProviderDiagnosticSheet({
} else { } else {
setDiagnostic(null); setDiagnostic(null);
setQuery(""); setQuery("");
bumpQueryResetKey();
} }
}, [visible, fetchDiagnostic]); }, [visible, fetchDiagnostic]);
@@ -418,17 +405,13 @@ export function ProviderDiagnosticSheet({
)); ));
} }
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: providerLabel, actions: headerActions }),
[providerLabel, headerActions],
);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={sheetHeader} title={providerLabel}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS} snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS}
headerActions={headerActions}
> >
<SettingsSection title="Diagnostic"> <SettingsSection title="Diagnostic">
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
@@ -451,8 +434,6 @@ export function ProviderDiagnosticSheet({
<View style={INLINE_ROW_STYLE}> <View style={INLINE_ROW_STYLE}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput <AdaptiveTextInput
initialValue={query}
resetKey={`provider-model-search-${queryResetKey}`}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Search models" placeholder="Search models"
@@ -106,7 +106,6 @@ import {
requireWorkspaceExecutionDirectory, requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import { import {
archiveWorkspaceOptimistically, archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically, archiveWorkspacesOptimistically,
@@ -594,7 +593,7 @@ function ProjectKebabMenu({
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
style={projectKebabStyle} style={projectKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole="button"
accessibilityLabel="Project actions" accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`} testID={`sidebar-project-kebab-${projectKey}`}
> >
@@ -725,7 +724,7 @@ function WorkspaceKebabMenu({
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
style={workspaceKebabStyle} style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole="button"
accessibilityLabel="Workspace actions" accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`} testID={`sidebar-workspace-kebab-${workspaceKey}`}
> >
@@ -895,7 +894,7 @@ function NewWorktreeButton({
style={pressableStyle} style={pressableStyle}
onPress={handlePress} onPress={handlePress}
disabled={loading} disabled={loading}
accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityRole="button"
accessibilityLabel={`Create a new workspace for ${displayName}`} accessibilityLabel={`Create a new workspace for ${displayName}`}
testID={testID} testID={testID}
> >
@@ -1173,13 +1172,10 @@ function ProjectHeaderRow({
return; return;
} }
router.navigate( router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href,
displayName,
projectId: project.projectKey,
}) as Href,
); );
onWorkspacePress?.(); onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]); }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const _toast = useToast(); const _toast = useToast();
@@ -1187,12 +1183,6 @@ function ProjectHeaderRow({
drag, drag,
menuController, menuController,
}); });
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) { if (interaction.didLongPressRef.current) {
@@ -1256,7 +1246,7 @@ function ProjectHeaderRow({
if (menuController) { if (menuController) {
return ( return (
<View <View
{...dragAttributes} {...dragHandleProps?.attributes}
{...dragHandleProps?.listeners} {...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>} ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter} onPointerEnter={handlePointerEnter}
@@ -1264,7 +1254,6 @@ function ProjectHeaderRow({
> >
<ContextMenuTrigger <ContextMenuTrigger
enabledOnMobile={false} enabledOnMobile={false}
accessibilityRole="button"
style={projectRowStyle} style={projectRowStyle}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove} onTouchMove={interaction.handleTouchMove}
@@ -1280,14 +1269,13 @@ function ProjectHeaderRow({
return ( return (
<View <View
{...dragAttributes} {...dragHandleProps?.attributes}
{...dragHandleProps?.listeners} {...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>} ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter} onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave} onPointerLeave={handlePointerLeave}
> >
<Pressable <Pressable
accessibilityRole="button"
style={projectRowStyle} style={projectRowStyle}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove} onTouchMove={interaction.handleTouchMove}
@@ -1336,12 +1324,6 @@ function WorkspaceRowInner({
drag, drag,
menuController, menuController,
}); });
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) { if (interaction.didLongPressRef.current) {
@@ -1383,7 +1365,7 @@ function WorkspaceRowInner({
return ( return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}> <WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View <View
{...dragAttributes} {...dragHandleProps?.attributes}
{...dragHandleProps?.listeners} {...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>} ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer} style={styles.workspaceRowContainer}
@@ -1492,92 +1474,91 @@ function WorkspaceRowWithMenu({
}); });
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const archiveWorktreeAfterConfirmation = useCallback(async () => { const handleArchiveWorktree = useCallback(() => {
if (isArchiving) { if (isArchiving) {
return; return;
} }
const confirmed = await confirmRiskyWorktreeArchive({ void (async () => {
worktreeName: workspace.name, const confirmed = await confirmDialog({
isDirty: workspace.archiveHasUncommittedChanges, title: "Archive worktree?",
aheadOfOrigin: workspace.archiveUnpushedCommitCount, message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
diffStat: workspace.diffStat, confirmLabel: "Archive",
}); cancelLabel: "Cancel",
destructive: true,
if (!confirmed) {
return;
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
}); });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!archiveDirectory) { if (!confirmed) {
toast.error("Workspace path not available"); return;
return; }
} let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
redirectAfterArchive(); if (!archiveDirectory) {
toast.error("Workspace path not available");
return;
}
void archiveWorktree({ redirectAfterArchive();
serverId: workspace.serverId,
cwd: archiveDirectory, void archiveWorktree({
worktreePath: archiveDirectory, serverId: workspace.serverId,
}).catch((error) => { cwd: archiveDirectory,
const message = error instanceof Error ? error.message : "Failed to archive worktree"; worktreePath: archiveDirectory,
toast.error(message); }).catch((error) => {
}); const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
})();
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) { if (isArchivingWorkspace) {
return; return;
} }
const confirmed = await confirmDialog({ void (async () => {
title: "Hide workspace?", const confirmed = await confirmDialog({
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, title: "Hide workspace?",
confirmLabel: "Hide", message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
cancelLabel: "Cancel", confirmLabel: "Hide",
destructive: true, cancelLabel: "Cancel",
}); destructive: true,
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
}); });
} catch (error) { if (!confirmed) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); return;
} finally { }
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => { const client = getHostRuntimeStore().getClient(workspace.serverId);
void hideWorkspaceAfterConfirmation(); if (!client) {
}, [hideWorkspaceAfterConfirmation]); toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
void (async () => {
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleCopyPath = useCallback(() => { const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string; let copyTargetDirectory: string;
@@ -1608,7 +1589,7 @@ function WorkspaceRowWithMenu({
priority: 0, priority: 0,
handle: () => { handle: () => {
if (isWorktree) { if (isWorktree) {
void archiveWorktreeAfterConfirmation(); handleArchiveWorktree();
} else { } else {
handleArchiveWorkspace(); handleArchiveWorkspace();
} }
@@ -23,13 +23,4 @@ describe("shouldFocusPaneFromEventTarget", () => {
).toBe(true); ).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true); expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
}); });
it("returns true for composer text inputs so focusing the composer focuses the pane", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: (selector: string) =>
selector.includes("input") ? ({ tagName: "INPUT" } as Element) : null,
} as unknown as EventTarget),
).toBe(true);
});
}); });
@@ -1,7 +1,9 @@
const INTERACTIVE_TARGET_SELECTOR = [ const INTERACTIVE_TARGET_SELECTOR = [
"a", "a",
"button", "button",
"input",
"select", "select",
"textarea",
"[role='button']", "[role='button']",
"[role='link']", "[role='link']",
"[contenteditable='true']", "[contenteditable='true']",
@@ -67,7 +67,7 @@ import {
type WorkspaceLayout, type WorkspaceLayout,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
interface SplitContainerProps { interface SplitContainerProps {
@@ -406,7 +406,6 @@ export function SplitContainer({
} }
return { kind: "pane" as const, pane: focusedPane }; return { kind: "pane" as const, pane: focusedPane };
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]); }, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current); const data = asWorkspaceTabDragData(event.active.data.current);
@@ -561,7 +560,7 @@ export function SplitContainer({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SplitNodeView <SplitNodeView
node={renderRoot} node={effectiveRoot}
workspaceKey={workspaceKey} workspaceKey={workspaceKey}
uiTabs={uiTabs} uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId} focusedPaneId={layout.focusedPaneId}
@@ -1072,22 +1071,6 @@ function getNodeKey(node: SplitNode): string {
return node.group.id; return node.group.id;
} }
function wrapRootPaneForStableMount(node: SplitNode): SplitNode {
if (node.kind === "group") {
return node;
}
return {
kind: "group",
group: {
id: `root:${node.pane.id}`,
direction: "horizontal",
children: [node],
sizes: [1],
},
};
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
group: { group: {
flex: 1, flex: 1,
@@ -17,7 +17,6 @@ import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -116,7 +115,6 @@ interface TerminalEmulatorProps {
streamKey: string; streamKey: string;
testId?: string; testId?: string;
xtermTheme?: ITheme; xtermTheme?: ITheme;
scrollbackLines: number;
swipeGesturesEnabled?: boolean; swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void; onSwipeLeft?: () => void;
onSwipeRight?: () => void; onSwipeRight?: () => void;
@@ -131,7 +129,6 @@ interface TerminalEmulatorProps {
meta: boolean; meta: boolean;
}) => Promise<void> | void; }) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void; onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers; pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number; focusRequestToken?: number;
@@ -189,7 +186,6 @@ export default function TerminalEmulator({
foreground: "#e6e6e6", foreground: "#e6e6e6",
cursor: "#e6e6e6", cursor: "#e6e6e6",
}, },
scrollbackLines,
swipeGesturesEnabled = false, swipeGesturesEnabled = false,
onSwipeLeft, onSwipeLeft,
onSwipeRight, onSwipeRight,
@@ -198,7 +194,6 @@ export default function TerminalEmulator({
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange, onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false }, pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0, focusRequestToken = 0,
@@ -208,8 +203,6 @@ export default function TerminalEmulator({
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null); const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const mountedThemeRef = useRef<ITheme>(xtermTheme); const mountedThemeRef = useRef<ITheme>(xtermTheme);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
const viewportRef = useRef<HTMLElement | null>(null); const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0); const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0); const dragStartClientYRef = useRef(0);
@@ -226,14 +219,12 @@ export default function TerminalEmulator({
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onInputModeChange,
}); });
mountCallbacksRef.current = { mountCallbacksRef.current = {
onInput, onInput,
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onInputModeChange,
}; };
const initialSnapshotRef = useRef(initialSnapshot); const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot; initialSnapshotRef.current = initialSnapshot;
@@ -293,10 +284,6 @@ export default function TerminalEmulator({
runtimeRef.current?.setTheme({ theme: nextTheme }); runtimeRef.current?.setTheme({ theme: nextTheme });
}, [themeKey]); }, [themeKey]);
useEffect(() => {
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => { useEffect(() => {
ensureTerminalScrollbarStyle(); ensureTerminalScrollbarStyle();
}, []); }, []);
@@ -437,7 +424,6 @@ export default function TerminalEmulator({
root, root,
host, host,
initialSnapshot: initialSnapshotRef.current, initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current, theme: mountedThemeRef.current,
}); });
onRendererReadyChangeRef.current?.({ streamKey, isReady: true }); onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
@@ -458,11 +444,10 @@ export default function TerminalEmulator({
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onInputModeChange,
onOpenExternalUrl: openExternalUrl, onOpenExternalUrl: openExternalUrl,
}, },
}); });
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]); }, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
useEffect(() => { useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers }); runtimeRef.current?.setPendingModifiers({ pendingModifiers });
+1 -20
View File
@@ -10,7 +10,6 @@ import {
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible"; import { useAppVisible } from "@/hooks/use-app-visible";
@@ -35,7 +34,6 @@ import {
shouldShowTerminalLoadingOverlay, shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange, type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness"; } from "@/utils/terminal-renderer-readiness";
import { useAppSettings } from "@/hooks/use-settings";
interface TerminalPaneProps { interface TerminalPaneProps {
serverId: string; serverId: string;
@@ -158,7 +156,6 @@ export function TerminalPane({
}: TerminalPaneProps) { }: TerminalPaneProps) {
const isAppVisible = useAppVisible(); const isAppVisible = useAppVisible();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { settings } = useAppSettings();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
@@ -191,10 +188,6 @@ export function TerminalPane({
const [resizeRequestToken, setResizeRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0);
const emulatorRef = useRef<TerminalEmulatorHandle>(null); const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId); const terminalIdRef = useRef<string>(terminalId);
const inputModeRef = useRef<TerminalInputModeState>({
kittyKeyboardFlags: 0,
win32InputMode: false,
});
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]); const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]); const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null); const lastAutoFocusKeyRef = useRef<string | null>(null);
@@ -202,10 +195,6 @@ export function TerminalPane({
useEffect(() => { useEffect(() => {
terminalIdRef.current = terminalId; terminalIdRef.current = terminalId;
inputModeRef.current = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
}, [terminalId]); }, [terminalId]);
const requestTerminalFocus = useCallback(() => { const requestTerminalFocus = useCallback(() => {
@@ -424,9 +413,7 @@ export function TerminalPane({
return true; return true;
} }
const encoded = encodeTerminalKeyInput(entry.input, { const encoded = encodeTerminalKeyInput(entry.input);
inputMode: inputModeRef.current,
});
if (encoded.length === 0) { if (encoded.length === 0) {
return true; return true;
} }
@@ -608,10 +595,6 @@ export function TerminalPane({
clearPendingModifiers(); clearPendingModifiers();
}, [clearPendingModifiers]); }, [clearPendingModifiers]);
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
const toggleModifier = useCallback( const toggleModifier = useCallback(
(modifier: keyof ModifierState) => { (modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
@@ -683,7 +666,6 @@ export function TerminalPane({
streamKey={terminalStreamKey} streamKey={terminalStreamKey}
testId="terminal-surface" testId="terminal-surface"
xtermTheme={xtermTheme} xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
swipeGesturesEnabled={swipeGesturesEnabled} swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot} initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange} onRendererReadyChange={handleRendererReadyChange}
@@ -692,7 +674,6 @@ export function TerminalPane({
onInput={handleTerminalData} onInput={handleTerminalData}
onResize={handleTerminalResize} onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey} onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onPendingModifiersConsumed={handlePendingModifiersConsumed} onPendingModifiersConsumed={handlePendingModifiersConsumed}
pendingModifiers={modifiers} pendingModifiers={modifiers}
focusRequestToken={focusRequestToken} focusRequestToken={focusRequestToken}
@@ -121,16 +121,6 @@ describe("filterAndRankComboboxOptions", () => {
const result = filterAndRankComboboxOptions(items, "202"); const result = filterAndRankComboboxOptions(items, "202");
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]); expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
}); });
it("matches fuzzy character sequences after stronger substring matches", () => {
const items = [
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-4.1", label: "GPT-4.1" },
{ id: "gemini", label: "Gemini" },
];
const result = filterAndRankComboboxOptions(items, "gpt54");
expect(result.map((o) => o.id)).toEqual(["gpt-5.4"]);
});
}); });
describe("combobox above-search ordering", () => { describe("combobox above-search ordering", () => {
@@ -1,4 +1,4 @@
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match"; import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
export type ComboboxOptionKind = "directory" | "file"; export type ComboboxOptionKind = "directory" | "file";
@@ -12,12 +12,18 @@ export interface ComboboxOptionModel {
const DESCRIPTION_FALLBACK_TIER = 99; const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null { function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const best = scoreTextFields(search, [opt.label, opt.id]); const labelScore = scoreMatch(search, opt.label);
const idScore = scoreMatch(search, opt.id);
let best: MatchScore | null = null;
if (labelScore) best = labelScore;
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
best = idScore;
}
if (best) return best; if (best) return best;
if (!opt.description) return null; if (opt.description && opt.description.toLowerCase().includes(search)) {
const descriptionScore = scoreTextFields(search, [opt.description]); return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
if (!descriptionScore) return null; }
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER }; return null;
} }
export interface BuildVisibleComboboxOptionsInput { export interface BuildVisibleComboboxOptionsInput {
+46 -116
View File
@@ -1,13 +1,5 @@
import { import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
useCallback, import type { ReactElement, ReactNode, Ref } from "react";
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ReactElement, ReactNode } from "react";
import { import {
View, View,
Text, Text,
@@ -26,6 +18,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
@@ -50,12 +43,6 @@ import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal"; } from "./isolated-bottom-sheet-modal";
import {
AdaptiveTextInput,
InlineHeaderView,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
const IS_WEB = isWeb; const IS_WEB = isWeb;
@@ -82,15 +69,6 @@ export interface ComboboxProps {
customValueKind?: "directory" | "file"; customValueKind?: "directory" | "file";
optionsPosition?: "below-search" | "above-search"; optionsPosition?: "below-search" | "above-search";
title?: string; title?: string;
/**
* Structured header. When provided, replaces `title` + `stickyHeader` and
* is rendered via the shared SheetHeaderView (mobile) / InlineHeaderView
* (desktop). Built-in search (when `searchable=true` and no `header.search`)
* is folded into the header so its magnifying glass aligns with the title
* and any leading icon at the sheet's shared indent.
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
@@ -158,7 +136,6 @@ export interface SearchInputProps {
onSubmitEditing?: () => void; onSubmitEditing?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
useBottomSheetInput?: boolean; useBottomSheetInput?: boolean;
resetKey?: string | number;
} }
export function SearchInput({ export function SearchInput({
@@ -168,10 +145,10 @@ export function SearchInput({
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
useBottomSheetInput = false, useBottomSheetInput = false,
resetKey,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -185,35 +162,18 @@ export function SearchInput({
return ( return (
<View style={styles.searchInputContainer}> <View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} /> <Search size={16} color={theme.colors.foregroundMuted} />
{useBottomSheetInput ? ( <InputComponent
<AdaptiveTextInput ref={inputRef as unknown as Ref<never>}
ref={inputRef} // @ts-expect-error - outlineStyle is web-only
// @ts-expect-error - outlineStyle is web-only style={SEARCH_INPUT_STYLE}
style={SEARCH_INPUT_STYLE} placeholder={placeholder}
placeholder={placeholder} placeholderTextColor={theme.colors.foregroundMuted}
placeholderTextColor={theme.colors.foregroundMuted} value={value}
initialValue={value} onChangeText={onChangeText}
resetKey={resetKey} autoCapitalize="none"
value={value} autoCorrect={false}
onChangeText={onChangeText} onSubmitEditing={onSubmitEditing}
autoCapitalize="none" />
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
) : (
<TextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
)}
</View> </View>
); );
} }
@@ -633,14 +593,12 @@ function useDesktopFloatingUpdate(
function useResetSearchOnOpen( function useResetSearchOnOpen(
isOpen: boolean, isOpen: boolean,
setSearchQueryWithCallback: (query: string) => void, setSearchQueryWithCallback: (query: string) => void,
bumpSearchResetKey: () => void,
) { ) {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSearchQueryWithCallback(""); setSearchQueryWithCallback("");
bumpSearchResetKey();
} }
}, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]); }, [isOpen, setSearchQueryWithCallback]);
} }
interface DesktopResetSetters { interface DesktopResetSetters {
@@ -961,13 +919,9 @@ interface MobileBodyProps {
handleIndicatorStyle: { backgroundColor: string }; handleIndicatorStyle: { backgroundColor: string };
titleColor: string; titleColor: string;
title: string; title: string;
header: SheetHeader | undefined;
onClose: () => void;
stickyHeader: ReactNode; stickyHeader: ReactNode;
searchable: boolean; searchable: boolean;
hasChildren: boolean; hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
searchResetKey: number;
searchPlaceholder: string; searchPlaceholder: string;
searchQuery: string; searchQuery: string;
setSearchQueryWithCallback: (query: string) => void; setSearchQueryWithCallback: (query: string) => void;
@@ -1027,40 +981,29 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
{props.header ? ( <View style={styles.bottomSheetHeader}>
<SheetHeaderView header={props.header} onClose={props.onClose} /> <Text key={props.titleColor} style={comboboxTitleStyle}>
) : ( {props.title}
<> </Text>
<View style={styles.bottomSheetHeader}> </View>
<Text key={props.titleColor} style={comboboxTitleStyle}> {props.stickyHeader}
{props.title} {!props.hasChildren && props.searchable ? (
</Text> <SearchInput
</View> placeholder={props.searchPlaceholder}
{props.stickyHeader} value={props.searchQuery}
{!props.hasChildren && props.searchable ? ( onChangeText={props.setSearchQueryWithCallback}
<SearchInput onSubmitEditing={props.handleSubmitSearch}
placeholder={props.searchPlaceholder} autoFocus={false}
value={props.searchQuery} useBottomSheetInput
onChangeText={props.setSearchQueryWithCallback} />
onSubmitEditing={props.handleSubmitSearch} ) : null}
autoFocus={false} <BottomSheetScrollView
useBottomSheetInput contentContainerStyle={styles.comboboxScrollContent}
resetKey={props.searchResetKey} keyboardShouldPersistTaps="handled"
/> showsVerticalScrollIndicator={false}
) : null} >
</> {body}
)} </BottomSheetScrollView>
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</IsolatedBottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -1072,7 +1015,6 @@ interface DesktopBodyProps {
shouldUseDesktopFade: boolean; shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown; desktopContainerStyle: unknown;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void; handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
searchable: boolean; searchable: boolean;
searchPlaceholder: string; searchPlaceholder: string;
@@ -1094,13 +1036,12 @@ interface DesktopBodyProps {
} }
function DesktopComboboxChildrenBody(props: { function DesktopComboboxChildrenBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
children: ReactNode; children: ReactNode;
}): ReactElement { }): ReactElement {
return ( return (
<> <>
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader} {props.stickyHeader}
<ScrollView <ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent} contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -1114,7 +1055,6 @@ function DesktopComboboxChildrenBody(props: {
} }
function DesktopComboboxOptionsBody(props: { function DesktopComboboxOptionsBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
searchable: boolean; searchable: boolean;
searchPlaceholder: string; searchPlaceholder: string;
@@ -1145,8 +1085,8 @@ function DesktopComboboxOptionsBody(props: {
return ( return (
<> <>
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader} {props.stickyHeader}
{props.header || !props.searchable ? null : ( {props.searchable ? (
<SearchInput <SearchInput
placeholder={props.searchPlaceholder} placeholder={props.searchPlaceholder}
value={props.searchQuery} value={props.searchQuery}
@@ -1155,7 +1095,7 @@ function DesktopComboboxOptionsBody(props: {
autoFocus autoFocus
useBottomSheetInput={false} useBottomSheetInput={false}
/> />
)} ) : null}
{props.effectiveOptionsPosition === "above-search" ? ( {props.effectiveOptionsPosition === "above-search" ? (
<ScrollView <ScrollView
ref={props.desktopOptionsScrollRef} ref={props.desktopOptionsScrollRef}
@@ -1201,12 +1141,11 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
onLayout={props.handleDesktopContentLayout} onLayout={props.handleDesktopContentLayout}
> >
{props.hasChildren ? ( {props.hasChildren ? (
<DesktopComboboxChildrenBody header={props.header} stickyHeader={props.stickyHeader}> <DesktopComboboxChildrenBody stickyHeader={props.stickyHeader}>
{props.children} {props.children}
</DesktopComboboxChildrenBody> </DesktopComboboxChildrenBody>
) : ( ) : (
<DesktopComboboxOptionsBody <DesktopComboboxOptionsBody
header={props.header}
stickyHeader={props.stickyHeader} stickyHeader={props.stickyHeader}
searchable={props.searchable} searchable={props.searchable}
searchPlaceholder={props.searchPlaceholder} searchPlaceholder={props.searchPlaceholder}
@@ -1249,8 +1188,6 @@ export function Combobox({
customValueKind, customValueKind,
optionsPosition = "below-search", optionsPosition = "below-search",
title = "Select", title = "Select",
header,
mobileChildrenScrollEnabled = true,
open, open,
onOpenChange, onOpenChange,
desktopPlacement = "top-start", desktopPlacement = "top-start",
@@ -1277,7 +1214,6 @@ export function Combobox({
const [referenceTop, setReferenceTop] = useState<number | null>(null); const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [activeIndex, setActiveIndex] = useState<number>(-1); const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null); const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null); const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
@@ -1298,10 +1234,9 @@ export function Combobox({
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false);
setSearchQueryWithCallback(""); setSearchQueryWithCallback("");
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]); }, [setOpen, setSearchQueryWithCallback]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey); useResetSearchOnOpen(isOpen, setSearchQueryWithCallback);
const collisionPadding = useMemo(computeCollisionPadding, []); const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1530,13 +1465,9 @@ export function Combobox({
handleIndicatorStyle={handleIndicatorStyle} handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor} titleColor={titleColor}
title={title} title={title}
header={header}
onClose={handleClose}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
searchable={searchable} searchable={searchable}
hasChildren={hasChildren} hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder} searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQueryWithCallback={setSearchQueryWithCallback} setSearchQueryWithCallback={setSearchQueryWithCallback}
@@ -1563,7 +1494,6 @@ export function Combobox({
shouldUseDesktopFade={shouldUseDesktopFade} shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle} desktopContainerStyle={desktopContainerStyle}
handleDesktopContentLayout={handleDesktopContentLayout} handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
searchable={searchable} searchable={searchable}
searchPlaceholder={effectiveSearchPlaceholder} searchPlaceholder={effectiveSearchPlaceholder}
@@ -1,213 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: GitHubSearchItem = {
kind: "pr",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature",
};
const issue202: GitHubSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
url: "https://github.com/acme/paseo/issues/202",
state: "open",
body: null,
labels: [],
baseRefName: null,
headRefName: null,
};
interface SearchCall {
cwd: string;
query: string;
limit?: number;
}
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialText?: string;
remote?: string | null;
}
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
return {
items,
githubFeaturesEnabled: true,
error: null,
requestId,
};
}
function createSearchClient(
items: GitHubSearchItem[],
): GitHubSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchGitHub(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
};
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
);
const autoAttach = useComposerGithubAutoAttach({
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client,
isConnected: true,
serverId: "server-1",
cwd,
setAttachments,
});
return {
text,
setText,
attachments,
setAttachments,
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
};
}
async function flushDebounce() {
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
await Promise.resolve();
});
}
describe("useComposerGithubAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
it("ignores URLs that do not match the current remote", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Other repo https://github.com/other/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not add a second pill when the ref is already attached", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Already here https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual(initialAttachments);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not re-add a GitHub ref removed earlier in the same composer session", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.markGithubAttachmentRemoved(initialAttachments[0]);
result.current.setAttachments([]);
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("handles multiple matching URLs from one paste", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101, issue202]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202",
);
});
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "github_pr", item: pr101 },
{ kind: "github_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
]);
vi.useRealTimers();
});
});
@@ -1,240 +0,0 @@
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useCallback,
useEffect,
useMemo,
useRef,
type Dispatch,
type RefObject,
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildGithubSearchQueryOptions,
type GitHubSearchClient,
} from "@/git/use-github-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@server/shared/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: GitHubSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
}
interface ComposerGithubAutoAttachResult {
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
}
export function useComposerGithubAutoAttach(
params: ComposerGithubAutoAttachInput,
): ComposerGithubAutoAttachResult {
const queryClient = useQueryClient();
const latestRef = useRef(params);
const removedRefKeysRef = useRef(new Set<string>());
const pendingRefKeysRef = useRef(new Set<string>());
latestRef.current = params;
useEffect(() => {
const refs = refsReadyForLookup({
params: latestRef.current,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
if (refs.length === 0) {
return;
}
const timerId = setTimeout(() => {
void attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
}, AUTO_ATTACH_DEBOUNCE_MS);
return () => {
clearTimeout(timerId);
};
}, [
params.text,
params.remoteUrl,
params.attachments,
params.client,
params.isConnected,
params.serverId,
params.cwd,
queryClient,
]);
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
const key = attachmentKey(attachment);
if (key) {
removedRefKeysRef.current.add(key);
}
}, []);
return useMemo(
() => ({
markGithubAttachmentRemoved,
}),
[markGithubAttachmentRemoved],
);
}
async function attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys,
pendingRefKeys,
}: {
refs: GithubRef[];
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): Promise<void> {
for (const ref of refs) {
const key = githubRefKey(ref);
if (pendingRefKeys.has(key)) {
continue;
}
pendingRefKeys.add(key);
try {
await attachRef({ ref, key, queryClient, latestRef, removedRefKeys });
} finally {
pendingRefKeys.delete(key);
}
}
}
async function attachRef({
ref,
key,
queryClient,
latestRef,
removedRefKeys,
}: {
ref: GithubRef;
key: string;
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
}): Promise<void> {
const snapshot = latestRef.current;
if (!snapshot.client || !snapshot.isConnected || !isRefStillPresent(ref, snapshot)) {
return;
}
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
if (!search) {
return;
}
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
return;
}
latestRef.current.setAttachments((current) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
return current;
}
return toggleGithubAttachment(current, item);
});
}
function refsReadyForLookup({
params,
removedRefKeys,
pendingRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): GithubRef[] {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
return [];
}
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
const key = githubRefKey(ref);
return (
!removedRefKeys.has(key) &&
!pendingRefKeys.has(key) &&
!hasGithubAttachment(params.attachments, ref)
);
});
}
async function fetchGithubRefSearch({
ref,
snapshot,
queryClient,
}: {
ref: GithubRef;
snapshot: ComposerGithubAutoAttachInput;
queryClient: QueryClient;
}) {
if (!snapshot.client) {
return null;
}
try {
return await queryClient.fetchQuery(
buildGithubSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
enabled: true,
}),
);
} catch {
return null;
}
}
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
return extractGithubRefs(params.text, params.remoteUrl).some(
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
);
}
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
return ref.kind === "pull" ? "pr" : "issue";
}
function githubRefKey(ref: GithubRef): string {
return `${githubItemKind(ref)}:${ref.number}`;
}
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
) {
return null;
}
return `${attachment.item.kind}:${attachment.item.number}`;
}
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
import { import {
type FlatList, type FlatList,
type LayoutChangeEvent, type LayoutChangeEvent,
@@ -21,25 +21,8 @@ function ensureHideScrollbarStyle(): void {
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return; if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
const style = document.createElement("style"); const style = document.createElement("style");
style.id = HIDE_SCROLLBAR_STYLE_ID; style.id = HIDE_SCROLLBAR_STYLE_ID;
style.textContent = ` style.textContent =
[data-hide-scrollbar] { "[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-gutter: auto;
}
[data-hide-scrollbar]::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
[data-hide-scrollbar]::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
`;
document.head.appendChild(style); document.head.appendChild(style);
} }
@@ -71,25 +54,18 @@ export function useWebElementScrollbar(
contentSize: 0, contentSize: 0,
}); });
useLayoutEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const element = elementRef.current; const element = elementRef.current;
if (!element) return; if (!element) return;
type ScrollbarStyle = CSSStyleDeclaration & {
scrollbarWidth: string;
msOverflowStyle: string;
scrollbarGutter: string;
};
const style = element.style as ScrollbarStyle;
const previousScrollbarWidth = style.scrollbarWidth;
const previousMsOverflowStyle = style.msOverflowStyle;
const previousScrollbarGutter = style.scrollbarGutter;
element.setAttribute("data-hide-scrollbar", ""); element.setAttribute("data-hide-scrollbar", "");
style.scrollbarWidth = "none"; (
style.msOverflowStyle = "none"; element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
style.scrollbarGutter = "auto"; ).scrollbarWidth = "none";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).msOverflowStyle = "none";
ensureHideScrollbarStyle(); ensureHideScrollbarStyle();
function update() { function update() {
@@ -118,9 +94,12 @@ export function useWebElementScrollbar(
element.removeEventListener("scroll", update); element.removeEventListener("scroll", update);
resizeObserver.disconnect(); resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar"); element.removeAttribute("data-hide-scrollbar");
style.scrollbarWidth = previousScrollbarWidth; (
style.msOverflowStyle = previousMsOverflowStyle; element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
style.scrollbarGutter = previousScrollbarGutter; ).scrollbarWidth = "";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).msOverflowStyle = "";
}; };
}, [contentRef, elementRef, enabled]); }, [contentRef, elementRef, enabled]);
@@ -8,10 +8,18 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Dimensions, Text, View } from "react-native"; import {
Dimensions,
Platform,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native"; import { ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
@@ -23,8 +31,6 @@ import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list"; import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
interface Rect { interface Rect {
x: number; x: number;
@@ -86,9 +92,8 @@ export function WorkspaceHoverCard({
isDragging, isDragging,
children, children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode { }: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
const isCompact = useIsCompactFormFactor(); // Desktop-only: skip on non-web platforms
if (Platform.OS !== "web") {
if (!isWeb || isCompact) {
return children; return children;
} }
@@ -311,62 +316,9 @@ function WorkspaceHoverCardContent({
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
function getChecksSummaryCounts(checks: NonNullable<PrHint["checks"]>) {
return checks.reduce(
(counts, check) => {
if (check.status === "success") counts.passed += 1;
else if (check.status === "failure") counts.failed += 1;
else if (check.status !== "skipped" && check.status !== "cancelled") counts.pending += 1;
return counts;
},
{ passed: 0, failed: 0, pending: 0 },
);
}
function ChecksSummaryPill({
count,
kind,
}: {
count: number;
kind: "passed" | "failed" | "pending";
}) {
if (count === 0) return null;
if (kind === "passed") {
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleCheck size={12} uniProps={successColorMapping} />
<Text style={styles.checksStatusTextPassed}>{count}</Text>
</View>
);
}
if (kind === "failed") {
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleX size={12} uniProps={dangerColorMapping} />
<Text style={styles.checksStatusTextFailed}>{count}</Text>
</View>
);
}
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleDot size={12} uniProps={warningColorMapping} />
<Text style={styles.checksStatusTextPending}>{count}</Text>
</View>
);
}
function ChecksSummaryContent({ function ChecksSummaryContent({
checks, checks,
@@ -375,7 +327,26 @@ function ChecksSummaryContent({
checks: NonNullable<PrHint["checks"]>; checks: NonNullable<PrHint["checks"]>;
hovered: boolean; hovered: boolean;
}) { }) {
const { passed, failed, pending } = getChecksSummaryCounts(checks); const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length;
let badgeLabel: string;
let dotStyle: StyleProp<ViewStyle>;
let statusTextStyle: StyleProp<TextStyle>;
if (failed > 0) {
badgeLabel = `${failed} failed`;
dotStyle = styles.checksDotFailed;
statusTextStyle = styles.checksStatusTextFailed;
} else if (pending > 0) {
badgeLabel = `${pending} running`;
dotStyle = styles.checksDotPending;
statusTextStyle = styles.checksStatusTextPending;
} else {
badgeLabel = `${checks.length} passed`;
dotStyle = styles.checksDotPassed;
statusTextStyle = styles.checksStatusTextPassed;
}
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel; const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping; const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
@@ -389,9 +360,8 @@ function ChecksSummaryContent({
)} )}
<Text style={labelStyle}>Checks</Text> <Text style={labelStyle}>Checks</Text>
<View style={styles.checksSummaryCounts}> <View style={styles.checksSummaryCounts}>
<ChecksSummaryPill count={passed} kind="passed" /> <View style={dotStyle} />
<ChecksSummaryPill count={failed} kind="failed" /> <Text style={statusTextStyle}>{badgeLabel}</Text>
<ChecksSummaryPill count={pending} kind="pending" />
</View> </View>
</> </>
); );
@@ -495,29 +465,42 @@ const styles = StyleSheet.create((theme) => ({
checksSummaryCounts: { checksSummaryCounts: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: 4,
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
checksSummaryPill: { checksDotFailed: {
flexDirection: "row", width: 6,
alignItems: "center", height: 6,
gap: 3, borderRadius: 3,
backgroundColor: theme.colors.palette.red[500],
},
checksDotPending: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.amber[500],
},
checksDotPassed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.green[500],
}, },
checksStatusTextFailed: { checksStatusTextFailed: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger, color: theme.colors.palette.red[500],
}, },
checksStatusTextPending: { checksStatusTextPending: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning, color: theme.colors.palette.amber[500],
}, },
checksStatusTextPassed: { checksStatusTextPassed: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess, color: theme.colors.palette.green[500],
}, },
})); }));
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native"; import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
@@ -374,18 +374,14 @@ export function WorkspaceSetupDialog() {
[iconSource, placeholderInitial, workspaceTitle], [iconSource, placeholderInitial, workspaceTitle],
); );
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: "Create workspace", subtitle: subtitleContent }),
[subtitleContent],
);
if (!pendingWorkspaceSetup || !sourceDirectory) { if (!pendingWorkspaceSetup || !sourceDirectory) {
return null; return null;
} }
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
header={sheetHeader} title="Create workspace"
subtitle={subtitleContent}
visible={true} visible={true}
onClose={handleClose} onClose={handleClose}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
});
});
});
@@ -1,85 +0,0 @@
import type { PaseoConfigRaw } from "@server/shared/messages";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
interface ReadProjectConfigResult {
ok: boolean;
config?: PaseoConfigRaw | null;
}
export interface WorktreeSetupCalloutPolicy {
id: string;
dismissalKey: string;
priority: number;
title: string;
description: string;
actionLabel: string;
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
testID: string;
}
export function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorktreeSetupWorkspaceInput,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
}
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
@@ -0,0 +1,279 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});
@@ -1,16 +1,48 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { import type { WorkspaceDescriptor } from "@/stores/session-store";
buildWorktreeSetupCalloutPolicy, import { buildProjectSettingsRoute } from "@/utils/host-routes";
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout, interface ActiveGitWorkspaceProject {
} from "./worktree-setup-callout-policy"; serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
export function WorktreeSetupCalloutSource() { export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
@@ -26,7 +58,7 @@ export function WorktreeSetupCalloutSource() {
if (!activeProject) { if (!activeProject) {
return; return;
} }
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute); router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
}); });
const readQuery = useQuery({ const readQuery = useQuery({
@@ -41,31 +73,29 @@ export function WorktreeSetupCalloutSource() {
retry: false, retry: false,
}); });
const calloutPolicy = useMemo( const shouldShow =
() => activeProject !== null &&
activeProject && shouldShowWorktreeSetupCallout(readQuery.data) readQuery.data?.ok === true &&
? buildWorktreeSetupCalloutPolicy(activeProject) !hasSetupCommands(readQuery.data.config ?? {});
: null,
[activeProject, readQuery.data],
);
useEffect(() => { useEffect(() => {
if (!calloutPolicy) { if (!shouldShow || !activeProject) {
return; return;
} }
return callouts.show({ return callouts.show({
id: calloutPolicy.id, id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: calloutPolicy.dismissalKey, dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: calloutPolicy.priority, priority: 100,
title: calloutPolicy.title, title: "Set up worktree scripts",
description: calloutPolicy.description, description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actions: [ actions: [
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" }, { label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
], ],
testID: calloutPolicy.testID, testID: `worktree-setup-callout-${activeProject.projectKey}`,
}); });
}, [calloutPolicy, callouts, openProjectSettings]); }, [activeProject, callouts, openProjectSettings, shouldShow]);
return null; return null;
} }
+1 -3
View File
@@ -14,9 +14,7 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates) // isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
// //
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts. // For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
// For hover-tracking, see docs/hover.md — the short answer is `onPointerEnter`/ // For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
// `onPointerLeave` on a plain `View`, with any press behavior on a separate
// inner `Pressable`. No platform gate needed.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Browser or Electron — the JS runtime has access to the DOM. */ /** Browser or Electron — the JS runtime has access to the DOM. */

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