Compare commits

...

17 Commits

Author SHA1 Message Date
Mohamed Boudra
63fe5dd0df chore(release): cut 0.1.78 2026-05-18 17:59:57 +07:00
Mohamed Boudra
1b9861846c Improve 0.1.78 changelog and add user-facing examples to release docs 2026-05-18 17:58:49 +07:00
Mohamed Boudra
68bce623c8 Route OpenCode live tests through OpenRouter
Big Pickle's models.dev metadata changed upstream to route via the
Anthropic SDK against the Zen OpenAI-compatible endpoint, which the
endpoint rejects. Swap the live-turn smoke tests to
openrouter/~openai/gpt-mini-latest and gate them on OPENROUTER_API_KEY
so fork PRs skip cleanly when the secret is absent. Also unpin
opencode-ai so CI tracks the latest CLI.
2026-05-18 17:16:57 +07:00
Mohamed Boudra
2072265b98 Stabilize OpenCode provider unit tests 2026-05-18 16:08:46 +07:00
Mohamed Boudra
f7ef0e0b84 Add 0.1.78 changelog 2026-05-18 16:08:21 +07:00
Mohamed Boudra
0419346d6a Pin OpenCode CLI in CI 2026-05-18 16:01:30 +07:00
paseo-ai[bot]
2866a12984 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 08:32:53 +00:00
Mohamed Boudra
120f5d94a9 Fix compact web sheet swipe crashes 2026-05-18 15:29:01 +07:00
Mohamed Boudra
29ce6653fd Improve mobile selectors and sheet inputs
Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
2026-05-18 14:56:17 +07:00
Mohamed Boudra
028839b3cb Add comparison pages under /docs/alternatives
Group the docs sidebar by directory so the new pages cluster
under a heading, add a hamburger menu for the mobile docs nav,
and let the docs main column shrink so wide code blocks and
tables scroll inside instead of pushing the page wide.
2026-05-18 12:59:04 +07:00
Mohamed Boudra
82319f5805 Keep agent panes mounted and focused 2026-05-18 11:24:56 +07:00
Mohamed Boudra
69fc6fe754 Document the positive submission-verification path (eas build:view Logs URL) 2026-05-18 10:16:42 +07:00
Mohamed Boudra
1b2a28be47 Drop eas submit:list references from release/android docs 2026-05-18 10:15:10 +07:00
Mohamed Boudra
665d9cedb5 Drop bogus eas submit:list from release docs and heartbeat checks 2026-05-18 10:14:29 +07:00
Mohamed Boudra
0d0012959a Lock babysit pattern: heartbeat to self, every 15m maxRuns 8 2026-05-18 09:59:00 +07:00
Mohamed Boudra
77b92b58f7 Drop trailing periods from changelog and fix mobile release docs
- Strip end-of-bullet periods from CHANGELOG.md (whole file)
- Tighten 0.1.77 TLS bullet to drop LAN/public explanation
- Release docs: drop release-mobile.yml references (workflow does not exist),
  add EAS mobile-build babysitting section using the EAS CLI, clarify that
  releases are always patch, clarify that "stable" means stable
- Changelog style guide: no trailing periods, one sentence per bullet, lead
  with capability not mechanism
2026-05-18 09:33:46 +07:00
paseo-ai[bot]
d4ebf1815c fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 02:26:22 +00:00
51 changed files with 2588 additions and 1494 deletions

View File

@@ -118,6 +118,7 @@ jobs:
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
strategy:

File diff suppressed because it is too large Load Diff

View File

@@ -48,24 +48,28 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- `.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.
`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.
`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.
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Recent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
# which has a Submissions section showing the auto-submit to the Play Store).
npx eas build:view <build-id>
```
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.

View File

@@ -21,11 +21,11 @@ Before running any stable patch release command:
npm run release:patch
```
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).
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.
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
**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).
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
**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".
## Manual step-by-step
@@ -147,6 +147,63 @@ 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.
- **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
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.
@@ -230,7 +287,20 @@ 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.
- **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`.
- **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.
- **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.
- **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.
@@ -241,9 +311,12 @@ 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.
- **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.
- **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.
- **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…".
- **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.
@@ -319,4 +392,5 @@ In other words, betas are checkpoints along the way; the changelog entry remains
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store

View File

@@ -1 +1 @@
sha256-SRax+Frqf0OhDhfWICSlhOnHBGsoHiXQc6P+zo5VjN8=
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=

34
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.77",
"version": "0.1.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.77",
"version": "0.1.78",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -7267,9 +7267,9 @@
}
},
"node_modules/@gorhom/bottom-sheet": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz",
"integrity": "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==",
"version": "5.2.14",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
"integrity": "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg==",
"license": "MIT",
"dependencies": {
"@gorhom/portal": "1.0.14",
@@ -39290,7 +39290,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.77",
"version": "0.1.78",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -39298,7 +39298,7 @@
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
@@ -39417,10 +39417,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.77",
"version": "0.1.78",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.77",
"@getpaseo/server": "0.1.78",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39463,7 +39463,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.77",
"version": "0.1.78",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -39512,7 +39512,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.77",
"version": "0.1.78",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39548,7 +39548,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.77",
"version": "0.1.78",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -39574,7 +39574,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.77",
"version": "0.1.78",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39589,12 +39589,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.77",
"version": "0.1.78",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.77",
"@getpaseo/relay": "0.1.77",
"@getpaseo/highlight": "0.1.78",
"@getpaseo/relay": "0.1.78",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -40137,7 +40137,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.77",
"version": "0.1.78",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.77",
"version": "0.1.78",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -1,9 +1,9 @@
name: Resubmit iOS for App Store review
# Standalone re-trigger for the App Store review submission step. The iOS
# binary is already uploaded to TestFlight via the main release-mobile flow;
# this workflow just runs the fastlane submit_review lane against the latest
# TestFlight build, without rebuilding or re-uploading.
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push
# build; this workflow just runs the fastlane submit_review lane against the
# latest TestFlight build, without rebuilding or re-uploading.
on:
workflow_dispatch:

View File

@@ -0,0 +1,64 @@
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();
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.77",
"version": "0.1.78",
"private": true,
"main": "index.ts",
"scripts": {
@@ -33,7 +33,7 @@
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",

View File

@@ -12,7 +12,7 @@ import {
BottomSheetTextInput,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
@@ -21,6 +21,37 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, 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;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
@@ -72,23 +103,30 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.surface2,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
headerContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
headerRow: {
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerBackButton: {
borderRadius: theme.borderRadius.lg,
},
headerLeadingSlot: {
alignItems: "center",
justifyContent: "center",
},
headerTitleGroup: {
flex: 1,
gap: theme.spacing[2],
gap: theme.spacing[1],
minWidth: 0,
},
title: {
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
@@ -96,52 +134,78 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
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: {
flexShrink: 1,
minHeight: 0,
},
desktopContent: {
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
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: {
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
flex: 1,
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
@@ -158,14 +222,186 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
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 {
title: string;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
header: SheetHeader;
visible: boolean;
onClose: () => void;
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
@@ -176,12 +412,10 @@ export interface AdaptiveModalSheetProps {
}
export function AdaptiveModalSheet({
title,
subtitle,
header,
visible,
onClose,
children,
headerActions,
snapPoints,
testID,
desktopMaxWidth,
@@ -190,7 +424,6 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
@@ -209,7 +442,6 @@ export function AdaptiveModalSheet({
[],
);
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
@@ -237,18 +469,7 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore"
accessible={false}
>
<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>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
@@ -266,18 +487,7 @@ export function AdaptiveModalSheet({
const cardInner = (
<>
<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>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
@@ -324,19 +534,3 @@ export function AdaptiveModalSheet({
</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 && isNative) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}
return <TextInput ref={ref} {...props} />;
},
);

View File

@@ -2,9 +2,11 @@ import { useCallback } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
const styles = StyleSheet.create((theme) => ({
option: {
flexDirection: "row",
@@ -62,7 +64,7 @@ export function AddHostMethodModal({
return (
<AdaptiveModalSheet
title="Add connection"
header={ADD_CONNECTION_HEADER}
visible={visible}
onClose={onClose}
testID="add-host-method-modal"

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useReducer, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -11,10 +11,11 @@ import {
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
interface DirectConnectionDraft {
host: string;
@@ -279,6 +280,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
setHost("");
@@ -288,6 +290,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
bumpInputResetKey();
}, []);
const connectIcon = useMemo(
@@ -411,6 +414,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
bumpInputResetKey();
} catch {
setErrorMessage("");
}
@@ -422,7 +426,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
return (
<AdaptiveModalSheet
title="Direct connection"
header={DIRECT_CONNECTION_HEADER}
visible={visible}
onClose={handleClose}
testID="add-host-modal"
@@ -436,6 +440,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host}
onChangeText={setHost}
placeholder="localhost"
@@ -454,6 +460,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port}
onChangeText={setPort}
placeholder="6767"
@@ -495,6 +503,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password}
onChangeText={setPassword}
placeholder="Optional"
@@ -537,6 +547,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"

View File

@@ -1,9 +1,13 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useReducer, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import {
buildAcpProviderConfigPatch,
@@ -26,6 +30,7 @@ type InstallState = "installed" | "available";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ACTION_BUTTON_STYLE = { width: 92 } as const;
const MODAL_SNAP_POINTS = ["78%", "92%"];
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24;
@@ -138,8 +143,15 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const [search, setSearch] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const handleClose = useCallback(() => {
setSearch("");
bumpSearchResetKey();
onClose();
}, [onClose]);
const installedProviderIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries],
@@ -157,7 +169,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
try {
await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]);
onClose();
handleClose();
} catch (installError) {
Alert.alert(
"Unable to install provider",
@@ -167,14 +179,14 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
[installingProviderId, onClose, patchConfig, refresh],
[installingProviderId, handleClose, patchConfig, refresh],
);
return (
<AdaptiveModalSheet
title="Add provider"
header={ADD_PROVIDER_HEADER}
visible={visible}
onClose={onClose}
onClose={handleClose}
desktopMaxWidth={680}
snapPoints={MODAL_SNAP_POINTS}
testID="add-provider-modal"
@@ -186,6 +198,8 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
<AdaptiveTextInput
testID="provider-catalog-search"
accessibilityLabel="Search providers"
initialValue={search}
resetKey={`provider-catalog-search-${searchResetKey}`}
value={search}
onChangeText={setSearch}
placeholder="Search providers"
@@ -216,7 +230,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
) : null}
<View style={styles.actions}>
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={onClose}>
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
Cancel
</Button>
</View>

View File

@@ -16,7 +16,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
@@ -48,7 +48,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentFeature,
@@ -58,14 +58,13 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest";
import type { Theme } from "@/styles/theme";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -184,21 +183,6 @@ const MODE_ICONS = {
ShieldOff,
ShieldQuestionMark,
} as const;
const ThemedShieldCheck = withUnistyles(ShieldCheck);
const ThemedShieldAlert = withUnistyles(ShieldAlert);
const ThemedShieldOff = withUnistyles(ShieldOff);
const ThemedShieldQuestionMark = withUnistyles(ShieldQuestionMark);
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const MODE_MENU_LEADING_ICONS = {
ShieldCheck: <ThemedShieldCheck size={16} uniProps={foregroundColorMapping} />,
ShieldAlert: <ThemedShieldAlert size={16} uniProps={foregroundColorMapping} />,
ShieldOff: <ThemedShieldOff size={16} uniProps={foregroundColorMapping} />,
ShieldQuestionMark: <ThemedShieldQuestionMark size={16} uniProps={foregroundColorMapping} />,
} as const;
function alwaysTrue() {
return true;
@@ -215,6 +199,16 @@ function resolveDisplayModel(
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({
providerOptions,
modeOptions,
@@ -274,18 +268,6 @@ 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({
nextProviderId,
modelId,
@@ -436,27 +418,6 @@ 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(
colorTier: AgentModeColorTier | undefined,
palette: {
@@ -508,7 +469,8 @@ function ControlledStatusBar({
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const [prefsOpen, setPrefsOpen] = useState(false);
const isCompact = useIsCompactFormFactor();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null);
@@ -526,7 +488,6 @@ function ControlledStatusBar({
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId);
const displayThinking = findOptionLabel(
thinkingOptions,
@@ -586,6 +547,18 @@ function ControlledStatusBar({
),
[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(
(selector: StatusSelector) =>
@@ -659,16 +632,30 @@ function ControlledStatusBar({
[canSelectMode, disabled, openSelector],
);
const handleOpenPrefs = useCallback(() => {
const handleOpenSheet = useCallback((sheet: Exclude<ActiveSheet, null>) => {
Keyboard.dismiss();
setPrefsOpen(true);
setActiveSheet(sheet);
}, []);
const handleClosePrefs = useCallback(() => {
setPrefsOpen(false);
const handleCloseSheet = useCallback(() => {
setActiveSheet(null);
}, []);
const prefsButtonStyle = makePrefsButtonStyle;
const handleSelectThinkingAndClose = useCallback(
(thinkingOptionId: string) => {
onSelectThinkingOption?.(thinkingOptionId);
setActiveSheet(null);
},
[onSelectThinkingOption],
);
const handleSelectModeAndClose = useCallback(
(modeId: string) => {
onSelectMode?.(modeId);
setActiveSheet(null);
},
[onSelectMode],
);
const handleSheetModelSelect = useCallback(
(nextProviderId: string, modelId: string) => {
@@ -684,38 +671,13 @@ function ControlledStatusBar({
[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) {
return null;
}
return (
<View style={styles.container}>
{platformIsWeb ? (
{!isCompact ? (
<DesktopStatusBarContent
provider={provider}
providerOptions={providerOptions}
@@ -770,23 +732,19 @@ function ControlledStatusBar({
handleModeOpenChange={handleModeOpenChange}
handleOpenChange={handleOpenChange}
renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/>
) : (
<SheetStatusBarContent
provider={provider}
modeOptions={modeOptions}
selectedModeId={selectedModeId}
selectedModelId={selectedModelId}
thinkingOptions={thinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
features={features}
onSetFeature={onSetFeature}
onSelectMode={onSelectMode}
onSelectThinkingOption={onSelectThinkingOption}
onToggleFavoriteModel={onToggleFavoriteModel}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
providerDefinitions={providerDefinitions}
favoriteKeys={favoriteKeys}
disabled={disabled}
isModelLoading={isModelLoading}
@@ -797,24 +755,21 @@ function ControlledStatusBar({
modelDisabled={modelDisabled}
effectiveProviderDefinitions={effectiveProviderDefinitions}
effectiveAllProviderModels={effectiveAllProviderModels}
displayMode={displayMode}
displayModel={displayModel}
displayThinking={displayThinking}
comboboxModeOptions={comboboxModeOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
ModeIconComponent={ModeIconComponent}
modeIconColor={modeIconColor}
openSelector={openSelector}
ProviderIcon={ProviderIcon}
prefsOpen={prefsOpen}
handleOpenPrefs={handleOpenPrefs}
handleClosePrefs={handleClosePrefs}
prefsButtonStyle={prefsButtonStyle}
sheetThinkingPressableStyle={sheetThinkingPressableStyle}
sheetModePressableStyle={sheetModePressableStyle}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
handleCloseSheet={handleCloseSheet}
handleSheetModelSelect={handleSheetModelSelect}
handleThinkingOpenChange={handleThinkingOpenChange}
handleModeOpenChange={handleModeOpenChange}
handleSelectThinkingAndClose={handleSelectThinkingAndClose}
handleSelectModeAndClose={handleSelectModeAndClose}
handleOpenChange={handleOpenChange}
renderSheetModelTrigger={renderSheetModelTrigger}
renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/>
)}
</View>
@@ -880,6 +835,12 @@ interface DesktopStatusBarContentProps {
active: boolean;
onPress: () => void;
}) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
}
const DESKTOP_SEARCH_THRESHOLD = 6;
@@ -938,6 +899,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
handleModeOpenChange,
handleOpenChange,
renderModeOption,
renderThinkingOption,
} = props;
return (
@@ -1033,6 +995,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
onOpenChange={handleThinkingOpenChange}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
renderOption={renderThinkingOption}
/>
</>
) : null}
@@ -1092,19 +1055,14 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
interface SheetStatusBarContentProps {
provider: string;
modeOptions?: StatusOption[];
selectedModeId?: string;
selectedModelId?: string;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onSelectMode?: (modeId: string) => void;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
providerDefinitions: AgentProviderDefinition[];
favoriteKeys: Set<string>;
disabled: boolean;
isModelLoading: boolean;
@@ -1115,43 +1073,45 @@ interface SheetStatusBarContentProps {
modelDisabled: boolean;
effectiveProviderDefinitions: AgentProviderDefinition[];
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
displayMode: string;
displayModel: string;
displayThinking: string;
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
modeIconColor: string;
openSelector: StatusSelector | null;
ProviderIcon: ReturnType<typeof getProviderIcon> | null;
prefsOpen: boolean;
handleOpenPrefs: () => void;
handleClosePrefs: () => void;
prefsButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetThinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetModePressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
handleSheetModelSelect: (providerId: string, modelId: string) => void;
handleThinkingOpenChange: (open: boolean) => void;
handleModeOpenChange: (open: boolean) => void;
handleSelectThinkingAndClose: (thinkingOptionId: string) => void;
handleSelectModeAndClose: (modeId: string) => void;
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void;
renderSheetModelTrigger: (args: { selectedModelLabel: string }) => ReactElement;
renderModeOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
}
function SheetStatusBarContent(props: SheetStatusBarContentProps) {
const { theme } = useUnistyles();
const {
provider,
modeOptions,
selectedModeId,
selectedModelId,
thinkingOptions,
selectedThinkingOptionId,
features,
onSetFeature,
onSelectMode,
onSelectThinkingOption,
onToggleFavoriteModel,
onDropdownClose,
onModelSelectorOpen,
providerDefinitions,
favoriteKeys,
disabled,
isModelLoading,
@@ -1162,133 +1122,194 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
modelDisabled,
effectiveProviderDefinitions,
effectiveAllProviderModels,
displayMode,
displayModel,
displayThinking,
comboboxModeOptions,
comboboxThinkingOptions,
ModeIconComponent,
modeIconColor,
openSelector,
ProviderIcon,
prefsOpen,
handleOpenPrefs,
handleClosePrefs,
prefsButtonStyle,
sheetThinkingPressableStyle,
sheetModePressableStyle,
activeSheet,
handleOpenSheet,
handleCloseSheet,
handleSheetModelSelect,
handleThinkingOpenChange,
handleModeOpenChange,
handleSelectThinkingAndClose,
handleSelectModeAndClose,
handleOpenChange,
renderSheetModelTrigger,
renderModeOption,
renderThinkingOption,
} = props;
return (
<>
<Pressable
onPress={handleOpenPrefs}
style={prefsButtonStyle}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
const thinkingAnchorRef = useRef<View | null>(null);
const modeAnchorRef = useRef<View | null>(null);
const hasThinking = comboboxThinkingOptions.length > 0;
const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0);
const hasFeatures = Boolean(features && features.length > 0);
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
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 size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel}
{shortModelLabel(selectedModelLabel)}
</Text>
</Pressable>
</View>
),
[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
title="Preferences"
visible={prefsOpen}
onClose={handleClosePrefs}
testID="agent-preferences-sheet"
header={FEATURES_SHEET_HEADER}
visible={activeSheet === "features"}
onClose={handleCloseSheet}
testID="agent-features-sheet"
>
{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) => (
{(features ?? []).map((feature) => (
<SheetFeatureItem
key={`feature-${feature.id}`}
feature={feature}
@@ -1555,23 +1576,28 @@ function FeatureOptionMenuItem({
);
}
function ThinkingMenuItem({
thinking,
function ThinkingComboboxOption({
option,
selected,
onSelectThinkingOption,
active,
onPress,
iconColor,
}: {
thinking: StatusOption;
option: ComboboxOption;
selected: boolean;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
active: boolean;
onPress: () => void;
iconColor: string;
}) {
const handleSelect = useCallback(() => {
onSelectThinkingOption?.(thinking.id);
}, [onSelectThinkingOption, thinking.id]);
const leadingSlot = useMemo(() => <Brain size={16} color={iconColor} />, [iconColor]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{thinking.label}
</DropdownMenuItem>
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
@@ -1609,33 +1635,8 @@ function ModeComboboxOption({
);
}
function ModeMenuItem({
mode,
provider,
providerDefinitions,
selected,
onSelectMode,
}: {
mode: StatusOption;
provider: string;
providerDefinitions: AgentProviderDefinition[];
selected: boolean;
onSelectMode?: (modeId: string) => void;
}) {
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const leadingIcon = visuals?.icon ? MODE_MENU_LEADING_ICONS[visuals.icon] : null;
const handleSelect = useCallback(() => {
onSelectMode?.(mode.id);
}, [mode.id, onSelectMode]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leadingIcon}>
{mode.label}
</DropdownMenuItem>
);
}
const EMPTY_MODES: AgentMode[] = [];
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentStatusBar = memo(function AgentStatusBar({
agentId,
@@ -1884,6 +1885,7 @@ export function DraftAgentStatusBar({
disabled = false,
}: DraftAgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -1931,7 +1933,7 @@ export function DraftAgentStatusBar({
[updatePreferences],
);
if (platformIsWeb) {
if (!isCompact) {
return (
<View style={styles.container}>
<CombinedModelSelector
@@ -2045,9 +2047,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
prefsButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,

View File

@@ -3,6 +3,7 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"
import {
buildModelRows,
buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
@@ -83,6 +84,34 @@ describe("combined model selector helpers", () => {
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", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");

View File

@@ -1,21 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Ref } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -45,19 +44,11 @@ function drillDownRowStyle({
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 {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
filterAndRankModelRows,
resolveProviderLabel,
type SelectorModelRow,
} from "./combined-model-selector.utils";
@@ -97,7 +88,6 @@ interface SelectorContentProps {
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
@@ -116,24 +106,6 @@ function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function partitionRows(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
const favoriteRows: SelectorModelRow[] = [];
const regularRows: SelectorModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favoriteRows.push(row);
continue;
}
regularRows.push(row);
}
return { favoriteRows, regularRows };
}
function sortFavoritesFirst(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
@@ -375,55 +347,23 @@ function GroupProviderButton({
function GroupedProviderRows({
groupedRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
viewKind,
}: {
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;
viewKind: SelectorView["kind"];
}) {
return (
<View>
{groupedRows.map((group, index) => {
const isInline = viewKind === "provider";
return (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<SelectableModelRow
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}
/>
)}
<GroupProviderButton
providerId={group.providerId}
providerLabel={group.providerLabel}
rowCount={group.rows.length}
onDrillDown={onDrillDown}
/>
</View>
);
})}
@@ -431,47 +371,65 @@ function GroupedProviderRows({
);
}
function ProviderSearchInput({
value,
onChangeText,
autoFocus = false,
function ProviderModelRows({
rows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
normalizedQuery,
}: {
value: string;
onChangeText: (text: string) => void;
autoFocus?: boolean;
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;
normalizedQuery: string;
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}, [autoFocus]);
const inputStyle = useMemo(
() => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }],
[],
const useVirtualizedList = isMobile && isNative;
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) {
return (
<BottomSheetFlatList
data={displayRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.virtualizedModelList}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.virtualizedModelListContent}
/>
);
}
return (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<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>
{displayRows.map((row) => (
<View key={row.favoriteKey}>{renderItem({ item: row })}</View>
))}
</View>
);
}
@@ -483,7 +441,6 @@ function SelectorContent({
selectedProvider,
selectedModel,
searchQuery,
onSearchChange: _onSearchChange,
favoriteKeys,
onSelect,
canSelectProvider,
@@ -506,92 +463,61 @@ function SelectorContent({
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const visibleRows = useMemo(
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
() => filterAndRankModelRows(scopedRows, normalizedQuery),
[normalizedQuery, scopedRows],
);
const { favoriteRows, regularRows: _regularRows } = useMemo(
() => partitionRows(visibleRows, favoriteKeys),
const favoriteRows = useMemo(
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, visibleRows],
);
// Group ALL visible rows by provider — favorites are a cross-cutting view,
// not a partition. A model being favorited doesn't remove it from its provider.
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
// When searching at Level 1, filter grouped rows to only providers whose name or models match
const filteredGroupedRows = useMemo(() => {
if (view.kind === "provider" || !normalizedQuery) {
return allGroupedRows;
}
return allGroupedRows.filter(
(group) =>
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
);
}, [allGroupedRows, normalizedQuery, view.kind]);
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
return (
<View>
{view.kind === "all" ? (
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
) : null}
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
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}
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>
);
}
function ProviderBackButton({
providerId,
providerLabel,
onBack,
}: {
providerId: string;
providerLabel: string;
onBack?: () => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
if (view.kind === "provider") {
if (visibleRows.length === 0) {
return emptyState;
}
if (!onBack) {
return null;
return (
<ProviderModelRows
rows={visibleRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
normalizedQuery={normalizedQuery}
/>
);
}
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>
<View>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{allGroupedRows.length > 0 ? (
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
) : null}
{!hasResults ? emptyState : null}
</View>
);
}
@@ -616,6 +542,7 @@ export function CombinedModelSelector({
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
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
const singleProviderView = useMemo<SelectorView | null>(() => {
@@ -646,6 +573,7 @@ export function CombinedModelSelector({
onOpen?.();
} else {
setSearchQuery("");
bumpSearchResetKey();
onClose?.();
}
},
@@ -657,6 +585,7 @@ export function CombinedModelSelector({
onSelect(provider, modelId);
setIsOpen(false);
setSearchQuery("");
bumpSearchResetKey();
},
[onSelect],
);
@@ -731,32 +660,48 @@ export function CombinedModelSelector({
const handleBackToAll = useCallback(() => {
setView({ kind: "all" });
setSearchQuery("");
bumpSearchResetKey();
}, []);
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
setView({ kind: "provider", providerId, providerLabel });
}, []);
const stickyHeader = useMemo(
() =>
view.kind === "provider" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={handleBackToAll}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={platformIsWeb}
/>
</View>
const handleSearchQueryChange = useCallback((value: string) => {
setSearchQuery(value);
}, []);
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
return { title: "Select provider" };
}
const ProviderIconForView = getProviderIcon(view.providerId);
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
) : undefined,
[view, singleProviderView, handleBackToAll, searchQuery],
);
back: singleProviderView ? undefined : { onPress: handleBackToAll },
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 (
<>
@@ -799,8 +744,8 @@ export function CombinedModelSelector({
desktopPlacement="top-start"
desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight}
title="Select model"
stickyHeader={stickyHeader}
header={sheetHeader}
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
>
{isContentReady ? (
<SelectorContent
@@ -810,7 +755,6 @@ export function CombinedModelSelector({
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
@@ -913,27 +857,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
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: {
paddingVertical: theme.spacing[4],
alignItems: "center",
@@ -943,6 +866,14 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
virtualizedModelList: {
flex: 1,
},
virtualizedModelListContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[8],
},
favoriteButton: {
width: 24,
height: 24,
@@ -966,17 +897,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
providerSearchContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[2],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
providerSearchInput: {
flex: 1,
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));

View File

@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type SelectorModelRow = FavoriteModelRow;
@@ -44,14 +45,33 @@ export function buildModelRows(
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
if (!normalizedQuery) {
return true;
}
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
.join(" ")
.toLowerCase();
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
return tokens.every((token) => haystack.includes(token));
return scoreModelRow(row, normalizedQuery) !== null;
}
function getModelRowSearchFields(row: SelectorModelRow): string[] {
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
}
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
}
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);
}

View File

@@ -741,6 +741,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isAgentRunning: boolean;
hasSendableContent: boolean;
isProcessing: boolean;
isCompact: boolean;
cancelButton: ReactElement;
}
@@ -750,10 +751,12 @@ function ComposerRightControlsSlot({
isAgentRunning,
hasSendableContent,
isProcessing,
isCompact,
cancelButton,
...voiceProps
}: ComposerRightControlsSlotProps) {
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
if (!showVoiceModeButton && !shouldShowCancelButton) return null;
return (
@@ -1401,6 +1404,7 @@ export function Composer({
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
isCompact={isMobile}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
@@ -1418,6 +1422,7 @@ export function Composer({
hasSendableContent,
isAgentRunning,
isConnected,
isMobile,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,

View File

@@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"];
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
@@ -25,7 +26,7 @@ export function KeyboardShortcutsDialog() {
return (
<AdaptiveModalSheet
title="Shortcuts"
header={SHORTCUTS_HEADER}
visible={open}
onClose={handleClose}
testID="keyboard-shortcuts-dialog"

View File

@@ -8,10 +8,11 @@ import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
const styles = StyleSheet.create((theme) => ({
helper: {
@@ -169,7 +170,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
return (
<AdaptiveModalSheet
title="Paste pairing link"
header={PAIR_LINK_HEADER}
visible={visible}
onClose={handleClose}
testID="pair-link-modal"

View File

@@ -1,5 +1,5 @@
import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -9,7 +9,11 @@ import {
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform";
@@ -96,6 +100,7 @@ function CustomModelsSection(props: {
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
@@ -136,7 +141,11 @@ function CustomModelsSection(props: {
label: trimmedInput,
},
])
.then(() => setInput(""))
.then(() => {
setInput("");
bumpInputResetKey();
return undefined;
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model");
})
@@ -163,6 +172,8 @@ function CustomModelsSection(props: {
<View style={settingsStyles.card}>
<View style={INLINE_ROW_STYLE}>
<AdaptiveTextInput
initialValue={input}
resetKey={`custom-model-input-${inputResetKey}`}
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
@@ -245,6 +256,7 @@ export function ProviderDiagnosticSheet({
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0);
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo(
@@ -348,6 +360,7 @@ export function ProviderDiagnosticSheet({
} else {
setDiagnostic(null);
setQuery("");
bumpQueryResetKey();
}
}, [visible, fetchDiagnostic]);
@@ -405,13 +418,17 @@ export function ProviderDiagnosticSheet({
));
}
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: providerLabel, actions: headerActions }),
[providerLabel, headerActions],
);
return (
<AdaptiveModalSheet
title={providerLabel}
header={sheetHeader}
visible={visible}
onClose={onClose}
snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS}
headerActions={headerActions}
>
<SettingsSection title="Diagnostic">
<View style={settingsStyles.card}>
@@ -434,6 +451,8 @@ export function ProviderDiagnosticSheet({
<View style={INLINE_ROW_STYLE}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
initialValue={query}
resetKey={`provider-model-search-${queryResetKey}`}
value={query}
onChangeText={setQuery}
placeholder="Search models"

View File

@@ -23,4 +23,13 @@ describe("shouldFocusPaneFromEventTarget", () => {
).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);
});
});

View File

@@ -1,9 +1,7 @@
const INTERACTIVE_TARGET_SELECTOR = [
"a",
"button",
"input",
"select",
"textarea",
"[role='button']",
"[role='link']",
"[contenteditable='true']",

View File

@@ -406,6 +406,7 @@ export function SplitContainer({
}
return { kind: "pane" as const, pane: focusedPane };
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
@@ -560,7 +561,7 @@ export function SplitContainer({
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={effectiveRoot}
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
@@ -1071,6 +1072,22 @@ function getNodeKey(node: SplitNode): string {
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) => ({
group: {
flex: 1,

View File

@@ -121,6 +121,16 @@ describe("filterAndRankComboboxOptions", () => {
const result = filterAndRankComboboxOptions(items, "202");
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", () => {

View File

@@ -1,4 +1,4 @@
import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
export type ComboboxOptionKind = "directory" | "file";
@@ -12,18 +12,12 @@ export interface ComboboxOptionModel {
const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
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;
}
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
if (opt.description && opt.description.toLowerCase().includes(search)) {
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
}
return null;
if (!opt.description) return null;
const descriptionScore = scoreTextFields(search, [opt.description]);
if (!descriptionScore) return null;
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
}
export interface BuildVisibleComboboxOptionsInput {

View File

@@ -1,5 +1,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode, Ref } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
@@ -18,7 +26,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import {
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
@@ -38,11 +45,17 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
AdaptiveTextInput,
InlineHeaderView,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
const IS_WEB = isWeb;
@@ -69,6 +82,15 @@ export interface ComboboxProps {
customValueKind?: "directory" | "file";
optionsPosition?: "below-search" | "above-search";
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;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
@@ -136,6 +158,7 @@ export interface SearchInputProps {
onSubmitEditing?: () => void;
autoFocus?: boolean;
useBottomSheetInput?: boolean;
resetKey?: string | number;
}
export function SearchInput({
@@ -145,10 +168,10 @@ export function SearchInput({
onSubmitEditing,
autoFocus = false,
useBottomSheetInput = false,
resetKey,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
@@ -162,18 +185,35 @@ export function SearchInput({
return (
<View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as unknown as Ref<never>}
// @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}
/>
{useBottomSheetInput ? (
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={value}
resetKey={resetKey}
value={value}
onChangeText={onChangeText}
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>
);
}
@@ -593,12 +633,14 @@ function useDesktopFloatingUpdate(
function useResetSearchOnOpen(
isOpen: boolean,
setSearchQueryWithCallback: (query: string) => void,
bumpSearchResetKey: () => void,
) {
useEffect(() => {
if (isOpen) {
setSearchQueryWithCallback("");
bumpSearchResetKey();
}
}, [isOpen, setSearchQueryWithCallback]);
}, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]);
}
interface DesktopResetSetters {
@@ -919,9 +961,13 @@ interface MobileBodyProps {
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
header: SheetHeader | undefined;
onClose: () => void;
stickyHeader: ReactNode;
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
searchResetKey: number;
searchPlaceholder: string;
searchQuery: string;
setSearchQueryWithCallback: (query: string) => void;
@@ -981,29 +1027,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
/>
) : null}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</IsolatedBottomSheetModal>
);
}
@@ -1015,6 +1072,7 @@ interface DesktopBodyProps {
shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
searchable: boolean;
searchPlaceholder: string;
@@ -1036,12 +1094,13 @@ interface DesktopBodyProps {
}
function DesktopComboboxChildrenBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode;
children: ReactNode;
}): ReactElement {
return (
<>
{props.stickyHeader}
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
<ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled"
@@ -1055,6 +1114,7 @@ function DesktopComboboxChildrenBody(props: {
}
function DesktopComboboxOptionsBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode;
searchable: boolean;
searchPlaceholder: string;
@@ -1085,8 +1145,8 @@ function DesktopComboboxOptionsBody(props: {
return (
<>
{props.stickyHeader}
{props.searchable ? (
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
{props.header || !props.searchable ? null : (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
@@ -1095,7 +1155,7 @@ function DesktopComboboxOptionsBody(props: {
autoFocus
useBottomSheetInput={false}
/>
) : null}
)}
{props.effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={props.desktopOptionsScrollRef}
@@ -1141,11 +1201,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
onLayout={props.handleDesktopContentLayout}
>
{props.hasChildren ? (
<DesktopComboboxChildrenBody stickyHeader={props.stickyHeader}>
<DesktopComboboxChildrenBody header={props.header} stickyHeader={props.stickyHeader}>
{props.children}
</DesktopComboboxChildrenBody>
) : (
<DesktopComboboxOptionsBody
header={props.header}
stickyHeader={props.stickyHeader}
searchable={props.searchable}
searchPlaceholder={props.searchPlaceholder}
@@ -1188,6 +1249,8 @@ export function Combobox({
customValueKind,
optionsPosition = "below-search",
title = "Select",
header,
mobileChildrenScrollEnabled = true,
open,
onOpenChange,
desktopPlacement = "top-start",
@@ -1214,6 +1277,7 @@ export function Combobox({
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
@@ -1234,9 +1298,10 @@ export function Combobox({
const handleClose = useCallback(() => {
setOpen(false);
setSearchQueryWithCallback("");
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1465,9 +1530,13 @@ export function Combobox({
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={title}
header={header}
onClose={handleClose}
stickyHeader={stickyHeader}
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery}
setSearchQueryWithCallback={setSearchQueryWithCallback}
@@ -1494,6 +1563,7 @@ export function Combobox({
shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}
searchable={searchable}
searchPlaceholder={effectiveSearchPlaceholder}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
@@ -374,14 +374,18 @@ export function WorkspaceSetupDialog() {
[iconSource, placeholderInitial, workspaceTitle],
);
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: "Create workspace", subtitle: subtitleContent }),
[subtitleContent],
);
if (!pendingWorkspaceSetup || !sourceDirectory) {
return null;
}
return (
<AdaptiveModalSheet
title="Create workspace"
subtitle={subtitleContent}
header={sheetHeader}
visible={true}
onClose={handleClose}
snapPoints={SNAP_POINTS}

View File

@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -127,7 +127,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Daemon logs"
header={DAEMON_LOGS_HEADER}
testID="managed-daemon-logs-dialog"
snapPoints={LOGS_MODAL_SNAP_POINTS}
>
@@ -158,7 +158,7 @@ function DaemonCliStatusModal({
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Daemon status"
header={DAEMON_STATUS_HEADER}
testID="daemon-cli-status-dialog"
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
@@ -512,3 +512,5 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };

View File

@@ -1,4 +1,4 @@
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
export interface PairDeviceModalProps {
@@ -8,11 +8,12 @@ export interface PairDeviceModalProps {
}
const SNAP_POINTS: string[] = ["82%", "94%"];
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
return (
<AdaptiveModalSheet
title="Pair a device"
header={PAIR_DEVICE_HEADER}
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}

View File

@@ -22,7 +22,7 @@ import { Alert } from "@/components/ui/alert";
import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
@@ -1150,11 +1150,15 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
const showNameError = touched.name && validation.nameError;
const showCommandError = touched.command && validation.commandError;
const isService = script.type === SCRIPT_SERVICE_TYPE;
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: script.name ? `Edit ${script.name}` : "New script" }),
[script.name],
);
return (
<AdaptiveModalSheet
visible
title={script.name ? `Edit ${script.name}` : "New script"}
header={sheetHeader}
onClose={onCancel}
testID="script-edit-modal"
desktopMaxWidth={560}

View File

@@ -18,7 +18,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -68,6 +68,10 @@ function formatDaemonVersionBadge(version: string | null): string | null {
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
}
const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" };
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
export interface HostPageProps {
serverId: string;
onHostRemoved?: () => void;
@@ -244,7 +248,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
<AdaptiveModalSheet
visible={isEditing}
onClose={handleCancel}
title="Rename host"
header={RENAME_HOST_HEADER}
testID="host-page-rename-modal"
>
<View style={styles.renameBody}>
@@ -347,7 +351,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
{pendingRemoveConnection ? (
<AdaptiveModalSheet
title="Remove connection"
header={REMOVE_CONNECTION_HEADER}
visible
onClose={handleCloseConfirm}
testID="remove-connection-confirm-modal"
@@ -723,7 +727,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
{isConfirming ? (
<AdaptiveModalSheet
title="Remove host"
header={REMOVE_HOST_HEADER}
visible
onClose={handleCloseConfirm}
testID="remove-host-confirm-modal"

View File

@@ -5,7 +5,7 @@ import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/clie
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { getProviderIcon } from "@/components/provider-icons";
@@ -13,6 +13,7 @@ import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
@@ -435,7 +436,7 @@ export function WorkspaceImportSheet({
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Import session"
header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}

View File

@@ -1,6 +1,7 @@
export interface MatchScore {
tier: number;
offset: number;
spread?: number;
}
function isWordBoundaryChar(ch: string | undefined): boolean {
@@ -8,19 +9,14 @@ function isWordBoundaryChar(ch: string | undefined): boolean {
return !/[a-z0-9]/.test(ch);
}
export function scoreMatch(query: string, text: string): MatchScore | null {
if (!query) return { tier: 0, offset: 0 };
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return { tier: 0, offset: 0 };
function scoreSubstringMatch(query: string, text: string): MatchScore | null {
let best: MatchScore | null = null;
let pos = 0;
while (pos <= t.length - q.length) {
const found = t.indexOf(q, pos);
while (pos <= text.length - query.length) {
const found = text.indexOf(query, pos);
if (found === -1) break;
const before = found > 0 ? t[found - 1] : undefined;
const after = t[found + q.length];
const before = found > 0 ? text[found - 1] : undefined;
const after = text[found + query.length];
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
let tier: number;
@@ -41,7 +37,57 @@ export function scoreMatch(query: string, text: string): MatchScore | null {
return best;
}
function scoreSubsequenceMatch(query: string, text: string): MatchScore | null {
let queryIndex = 0;
let firstIndex = -1;
let lastIndex = -1;
for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
if (text[textIndex] !== query[queryIndex]) continue;
if (firstIndex === -1) firstIndex = textIndex;
lastIndex = textIndex;
queryIndex += 1;
}
if (queryIndex !== query.length || firstIndex === -1) return null;
return { tier: 5, offset: firstIndex, spread: lastIndex - firstIndex + 1 };
}
export function scoreMatch(query: string, text: string): MatchScore | null {
if (!query) return { tier: 0, offset: 0 };
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return { tier: 0, offset: 0 };
return scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
}
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
if (a.tier !== b.tier) return a.tier - b.tier;
return a.offset - b.offset;
if (a.offset !== b.offset) return a.offset - b.offset;
return (a.spread ?? 0) - (b.spread ?? 0);
}
export function scoreTextFields(query: string, fields: string[]): MatchScore | null {
const tokens = query
.trim()
.toLowerCase()
.split(/\s+/)
.filter((token) => token.length > 0);
if (tokens.length === 0) return { tier: 0, offset: 0, spread: 0 };
const aggregate: MatchScore = { tier: 0, offset: 0, spread: 0 };
for (const token of tokens) {
let best: MatchScore | null = null;
for (const field of fields) {
const score = scoreMatch(token, field);
if (score && (!best || compareMatchScores(score, best) < 0)) {
best = score;
}
}
if (!best) return null;
aggregate.tier += best.tier;
aggregate.offset += best.offset;
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
}
return aggregate;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.77",
"version": "0.1.78",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.77",
"@getpaseo/server": "0.1.78",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.77",
"version": "0.1.78",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.77",
"version": "0.1.78",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.77",
"version": "0.1.78",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.77",
"version": "0.1.78",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.77",
"version": "0.1.78",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,8 +58,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.77",
"@getpaseo/relay": "0.1.77",
"@getpaseo/highlight": "0.1.78",
"@getpaseo/relay": "0.1.78",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",

View File

@@ -34,7 +34,7 @@ function tmpCwd(): string {
}
}
const TEST_MODEL = "opencode/big-pickle";
const TEST_MODEL = "openrouter/~openai/gpt-mini-latest";
interface TurnResult {
events: AgentStreamEvent[];
@@ -92,8 +92,10 @@ function isBinaryInstalled(binary: string): boolean {
}
const hasOpenCode = isBinaryInstalled("opencode");
const hasOpenRouterKey = Boolean(process.env.OPENROUTER_API_KEY);
const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
(hasOpenCode ? describe : describe.skip)("OpenCodeAgentClient", () => {
(canRunLiveOpenCodeTurns ? describe : describe.skip)("OpenCodeAgentClient smoke tests", () => {
const logger = createTestLogger();
const buildConfig = (cwd: string): AgentSessionConfig => ({
provider: "opencode",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.77",
"version": "0.1.78",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,7 @@
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { getDocs } from "~/docs";
import { Menu, X } from "lucide-react";
import { useCallback, useState } from "react";
import { type Doc, getDocs } from "~/docs";
import "~/styles.css";
export const Route = createFileRoute("/docs")({
@@ -10,57 +12,121 @@ const ACTIVE_OPTIONS_EXACT = { exact: true };
const MOBILE_ACTIVE_PROPS = { className: "text-foreground" };
const DESKTOP_ACTIVE_PROPS = { className: "bg-muted text-foreground" };
interface NavItem {
name: string;
href: string;
}
interface NavGroup {
name: string | null;
items: NavItem[];
}
const GROUP_LABELS: Record<string, string> = {
alternatives: "Alternatives",
};
function groupKey(doc: Doc): string | null {
const idx = doc.slug.indexOf("/");
return idx === -1 ? null : doc.slug.slice(0, idx);
}
function buildNavigation(): NavGroup[] {
const groups = new Map<string | null, NavItem[]>();
for (const doc of getDocs()) {
const key = groupKey(doc);
const items = groups.get(key) ?? [];
items.push({ name: doc.frontmatter.nav, href: doc.href });
groups.set(key, items);
}
const ordered: NavGroup[] = [];
if (groups.has(null)) ordered.push({ name: null, items: groups.get(null)! });
for (const [key, items] of groups) {
if (key === null) continue;
ordered.push({ name: GROUP_LABELS[key] ?? key, items });
}
return ordered;
}
function DocsLayout() {
const navigation = getDocs().map((doc) => ({
name: doc.frontmatter.nav,
href: doc.href,
}));
const groups = buildNavigation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const toggleMobileNav = useCallback(() => setMobileNavOpen((v) => !v), []);
const closeMobileNav = useCallback(() => setMobileNavOpen(false), []);
return (
<div className="min-h-screen bg-background">
{/* Mobile header */}
<header className="md:hidden border-b border-border p-4">
<Link to="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</Link>
<nav className="flex gap-4 mt-4 flex-wrap">
{navigation.map((item) => (
<Link
key={item.href}
to={item.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
activeProps={MOBILE_ACTIVE_PROPS}
>
{item.name}
</Link>
))}
</nav>
<header className="md:hidden sticky top-0 z-50 bg-background border-b border-border">
<div className="flex items-center justify-between p-4">
<Link to="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</Link>
<button
type="button"
onClick={toggleMobileNav}
aria-label={mobileNavOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileNavOpen}
className="-mr-2 p-2 text-muted-foreground hover:text-foreground transition-colors"
>
{mobileNavOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
{mobileNavOpen && (
<nav className="border-t border-border px-4 py-4 space-y-4 max-h-[calc(100dvh-4rem)] overflow-y-auto">
{groups.map((group) => (
<div key={group.name ?? "root"} className="space-y-1">
{group.name && (
<div className="text-sm font-medium text-foreground mb-2">{group.name}</div>
)}
{group.items.map((item) => (
<Link
key={item.href}
to={item.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={closeMobileNav}
className="block py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
activeProps={MOBILE_ACTIVE_PROPS}
>
{item.name}
</Link>
))}
</div>
))}
</nav>
)}
</header>
<div className="flex">
{/* Desktop sidebar */}
<aside className="hidden md:block w-56 shrink-0 border-r border-border p-6 sticky top-0 h-screen">
<Link to="/" className="flex items-center gap-3 mb-8">
<aside className="hidden md:flex md:flex-col w-56 shrink-0 border-r border-border p-6 sticky top-0 h-screen">
<Link to="/" className="flex items-center gap-3 mb-8 shrink-0">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</Link>
<nav className="space-y-1 -ml-3">
{navigation.map((item) => (
<Link
key={item.href}
to={item.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
className="block px-3 py-2 text-sm rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
activeProps={DESKTOP_ACTIVE_PROPS}
>
{item.name}
</Link>
<nav className="flex-1 min-h-0 overflow-y-auto -ml-3 -mr-3 pr-3 space-y-4">
{groups.map((group) => (
<div key={group.name ?? "root"} className="space-y-1">
{group.name && (
<div className="px-3 py-2 text-sm font-medium text-foreground">{group.name}</div>
)}
{group.items.map((item) => (
<Link
key={item.href}
to={item.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
className="block px-3 py-2 text-sm rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
activeProps={DESKTOP_ACTIVE_PROPS}
>
{item.name}
</Link>
))}
</div>
))}
</nav>
</aside>
<main className="flex-1 p-6 md:p-12 max-w-3xl docs-prose">
<main className="flex-1 min-w-0 p-6 md:p-12 max-w-3xl docs-prose">
<Outlet />
</main>
</div>

View File

@@ -232,6 +232,8 @@
margin-top: 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
display: block;
overflow-x: auto;
}
.docs-prose th,

View File

@@ -0,0 +1,60 @@
diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js
index 76eb808..0debe83 100644
--- a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js
+++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js
@@ -58,7 +58,14 @@ class PointerEventManager extends _EventManager.default {
const adaptedEvent = this.mapEvent(event, _interfaces.EventTypes.UP);
const target = event.target;
if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) {
- target.releasePointerCapture(adaptedEvent.pointerId);
+ try {
+ if (target.hasPointerCapture(adaptedEvent.pointerId)) {
+ target.releasePointerCapture(adaptedEvent.pointerId);
+ }
+ } catch {
+ // Pointer capture is implicitly released after pointerup/pointercancel.
+ // Some browsers can report stale capture state during gesture teardown.
+ }
}
this.markAsOutOfBounds(adaptedEvent.pointerId);
this.trackedPointers.delete(adaptedEvent.pointerId);
diff --git a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js
index 6ce3b8f..3976f3f 100644
--- a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js
+++ b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js
@@ -53,7 +53,14 @@ export default class PointerEventManager extends EventManager {
const adaptedEvent = this.mapEvent(event, EventTypes.UP);
const target = event.target;
if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) {
- target.releasePointerCapture(adaptedEvent.pointerId);
+ try {
+ if (target.hasPointerCapture(adaptedEvent.pointerId)) {
+ target.releasePointerCapture(adaptedEvent.pointerId);
+ }
+ } catch {
+ // Pointer capture is implicitly released after pointerup/pointercancel.
+ // Some browsers can report stale capture state during gesture teardown.
+ }
}
this.markAsOutOfBounds(adaptedEvent.pointerId);
this.trackedPointers.delete(adaptedEvent.pointerId);
diff --git a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts
index 18d3627..769b0fe 100644
--- a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts
+++ b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts
@@ -67,7 +67,14 @@ export default class PointerEventManager extends EventManager<HTMLElement> {
const target = event.target as HTMLElement;
if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) {
- target.releasePointerCapture(adaptedEvent.pointerId);
+ try {
+ if (target.hasPointerCapture(adaptedEvent.pointerId)) {
+ target.releasePointerCapture(adaptedEvent.pointerId);
+ }
+ } catch {
+ // Pointer capture is implicitly released after pointerup/pointercancel.
+ // Some browsers can report stale capture state during gesture teardown.
+ }
}
this.markAsOutOfBounds(adaptedEvent.pointerId);

View File

@@ -0,0 +1,95 @@
---
title: Open source Conductor alternative with Linux, Windows, and mobile
description: Paseo is open source, runs on macOS, Linux, and Windows, ships native iOS and Android apps, and supports 30+ agents through the in-app catalog plus any ACP or CLI agent. Conductor is macOS only and Claude Code or Codex only.
nav: Conductor
order: 100
---
# Paseo vs Conductor
Conductor is a macOS app for running Claude Code and Codex in parallel git worktrees. Closed source.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
## Why pick Paseo
Conductor runs on macOS, with Claude Code and Codex, in parallel git worktrees. Paseo does all of that. Pick Paseo if you want:
- Linux or Windows alongside macOS
- A native iOS and Android app
- Many more agents than Claude Code and Codex
- A CLI to script agent work and drive remote daemons
- A self-hosted daemon you can run on a server, VM, or homelab
- Open source you can audit and fork
## Architecture
The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI all connect to it over a websocket. Run the daemon on your laptop, on a VM, in Docker, or across a fleet, and connect to any of them from any client.
Conductor's desktop app is the host. Agents run inside it.
## Providers
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Conductor runs Claude Code and Codex.
Both tools launch the official CLIs as subprocesses with your own credentials. Neither extracts tokens or proxies model calls.
## Panes
Paseo's app has split panes and tabs (⌘D for vertical, ⌘⇧D for horizontal). Panes include a terminal alongside your agents, a diff viewer, and a browser for testing running services.
## GitHub
Paseo's app handles commit, push, opening PRs, watching checks and reviews, and merging.
## CLI
Paseo has a CLI that mirrors the app:
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
Conductor does not have a CLI.
## Worktrees and services
Both tools isolate parallel agents in git worktrees.
Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions.
## Mobile
Paseo ships native iOS and Android apps with the same feature set as the desktop app. Conductor has no mobile app.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. Nothing leaves your network. Conductor does not have voice.
## Comparison
| | Paseo | Conductor |
| ---------------------------- | --------------------------------------------------------------- | ------------------ |
| License | Open source (AGPL-3.0) | Closed source |
| Platforms | macOS, Linux, Windows | macOS only |
| Native mobile | iOS, Android | — |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | Yes |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Superset](/docs/alternatives/superset), [Paseo vs OpenChamber](/docs/alternatives/openchamber), [Paseo vs Happy Coder](/docs/alternatives/happy-coder).

View File

@@ -0,0 +1,97 @@
---
title: Happy Coder alternative with a desktop app and git worktrees
description: Paseo ships a native desktop app, runs agents in isolated git worktrees, and supports 30+ agents. Happy Coder is mobile and web only, wraps the agent CLI, and supports Claude Code and Codex.
nav: Happy Coder
order: 104
---
# Paseo vs Happy Coder
Happy Coder is a mobile and web client for Claude Code and Codex. It wraps the agent CLI on your laptop and syncs sessions to phone and browser over an end-to-end encrypted relay. Open source under MIT.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
## When to pick what
Pick Happy Coder if you want the most minimal setup. Wrap an existing Claude Code or Codex session on your laptop and check in on it from your phone.
Pick Paseo if you want:
- A native desktop app on macOS, Linux, and Windows
- Git worktrees for parallel agents
- Per-worktree dev server URLs
- GitHub PRs, checks, reviews, and merges in the app
- Many more agents than Claude Code and Codex
- A CLI to script agent work and drive remote daemons
## Architecture
Paseo runs the agent inside its own daemon. The daemon owns the agent lifecycle, the worktree, and the dev servers. Clients connect over a websocket and drive the daemon.
Happy Coder runs the agent inside its existing CLI on your laptop and syncs the session to its mobile and web clients through an end-to-end encrypted relay.
## Panes
Paseo's app has split panes and tabs (⌘D for vertical, ⌘⇧D for horizontal). Panes include a terminal alongside your agents, a diff viewer, and a browser for testing running services.
Happy Coder does not have a desktop app.
## GitHub
Paseo's app handles commit, push, opening PRs, watching checks and reviews, and merging.
## Mobile
Both tools ship native iOS and Android apps.
## Providers
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
Happy Coder runs Claude Code and Codex.
## Worktrees and services
Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port.
Happy Coder runs the agent in the directory you launched the CLI from.
## CLI
Paseo has a CLI that mirrors the app:
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
Happy Coder has a CLI to launch the wrapped session. It does not have schedules or loops.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. Nothing leaves your network.
## Comparison
| | Paseo | Happy Coder |
| ---------------------------- | --------------------------------------------------------------- | ---------------------- |
| License | Open source (AGPL-3.0) | Open source (MIT) |
| Desktop app | macOS, Linux, Windows | — |
| Native mobile | iOS, Android | iOS, Android |
| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | — |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — |
| Git worktrees | Yes | — |
| Per-worktree dev server URLs | Yes | — |
| CLI | Run, `--host`, ls, send, schedule, loop | Launch wrapped session |
| Local voice (on-device) | Yes | — |
See also: [Paseo vs Conductor](/docs/alternatives/conductor), [Paseo vs Superset](/docs/alternatives/superset), [Paseo vs OpenChamber](/docs/alternatives/openchamber).

View File

@@ -0,0 +1,91 @@
---
title: OpenChamber alternative with Linux, Windows, and mobile
description: Paseo ships native iOS and Android apps, runs on macOS, Linux, and Windows, and supports 30+ agents. OpenChamber is macOS only with a PWA and is built around OpenCode.
nav: OpenChamber
order: 103
---
# Paseo vs OpenChamber
OpenChamber is a macOS desktop app for OpenCode. Also available as a PWA. Open source under MIT.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
## Why pick Paseo
OpenChamber runs on macOS, around OpenCode, with a phone PWA. Paseo runs OpenCode too, on macOS, and adds:
- Linux and Windows desktop
- A native iOS and Android app
- Many more agents than OpenCode (Claude Code, Codex, Pi, plus 30+ more via the in-app ACP catalog)
- A scriptable CLI to drive agents and connect to remote daemons
## Mobile
Paseo ships a native iOS and Android app with the same feature set as the desktop. Install from the App Store or Google Play.
OpenChamber does not have a native mobile app.
## Desktop
Paseo ships on macOS, Linux, and Windows.
OpenChamber ships on macOS.
## Providers
Paseo runs Claude Code, Codex, OpenCode, and Pi natively, plus 30+ more agents through the in-app catalog including GitHub Copilot, Cursor, Gemini CLI, and Amp. Paseo speaks the [Agent Client Protocol](https://agentclientprotocol.com), so any ACP agent works. Custom providers run any CLI agent. See [Supported providers](/docs/supported-providers).
OpenChamber is built around OpenCode.
## Panes
Paseo's app has split panes and tabs (⌘D for vertical, ⌘⇧D for horizontal). Panes include a terminal alongside your agents, a diff viewer, and a browser for testing running services.
## GitHub
Paseo's app handles commit, push, opening PRs, watching checks and reviews, and merging.
## CLI
Paseo has a CLI that mirrors the app:
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
OpenChamber does not have a CLI.
## Worktrees and services
Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. OpenChamber does not have voice.
## Comparison
| | Paseo | OpenChamber |
| ---------------------------- | --------------------------------------------------------------- | ----------------- |
| License | Open source (AGPL-3.0) | Open source (MIT) |
| Desktop platforms | macOS, Linux, Windows | macOS |
| Mobile | Native iOS, Android | PWA |
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode |
| Split panes and tabs | Yes | — |
| In-app terminal | Yes | — |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Conductor](/docs/alternatives/conductor), [Paseo vs Superset](/docs/alternatives/superset), [Paseo vs Happy Coder](/docs/alternatives/happy-coder).

View File

@@ -0,0 +1,114 @@
---
title: Superset alternative with Linux, Windows, and mobile
description: Paseo is open source under an OSI license, has no login wall, ships native mobile, and runs on macOS, Linux, and Windows. Superset is source-available, macOS only, and gates the desktop app on a Superset login.
nav: Superset
order: 101
---
# Paseo vs Superset
Superset is a macOS desktop app for running CLI coding agents in parallel git worktrees. Source-available under the Elastic License 2.0.
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
## When to pick what
Pick Superset if you prefer a terminal-first interface where agents live inside terminal panes.
Pick Paseo if you want:
- An OSI-approved open source license (AGPL-3.0)
- Linux or Windows
- A native mobile app
- No login wall
- A per-agent UI with modes, slash commands, and file pickers
- Free without seat limits
## License
Paseo is open source under AGPL-3.0. Audit it, fork it, redistribute it.
Superset is source-available under the Elastic License 2.0. The source is on GitHub, but the license restricts hosting it as a service and limits redistribution.
## Login
Superset's desktop app shows a Superset login wall on first launch. A Superset account is required to use it.
Paseo does not require any login.
## Architecture
The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it. Run the daemon on your laptop, on a server, or in Docker, and connect from anywhere.
Superset's desktop is the host. Agents run inside it.
## Providers
Both tools support many agents. Superset is a terminal multiplexer where each agent runs inside a terminal pane. Paseo runs Claude Code, Codex, OpenCode, and Pi natively with a per-agent UI (modes, slash commands, file picker, diff viewer), plus 30+ more agents through the in-app catalog via ACP, plus any custom CLI agent. See [Supported providers](/docs/supported-providers).
## Panes
Paseo's app has split panes and tabs. Panes include a diff viewer and a browser for testing running services. Agents render as native UI with modes, slash commands, and file pickers.
In Superset, each agent runs inside a terminal pane.
## GitHub
Paseo's app handles commit, push, opening PRs, watching checks and reviews, and merging.
## CLI
Paseo has a CLI that mirrors the app:
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
Superset is a desktop app and does not have a CLI.
## Worktrees and services
Both tools isolate parallel agents in git worktrees.
Paseo also gives each worktree its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports.
## Mobile
Paseo ships native iOS and Android apps with the same feature set as the desktop. Superset does not have a mobile app.
## Voice
Paseo's speech-to-text and text-to-speech run locally on your device. Superset does not have voice.
## Pricing
Paseo is free with no seat limits.
Superset is free for one seat with local workspaces only. Team features and sync start at $20 per seat per month.
## Comparison
| | Paseo | Superset |
| ---------------------------- | ----------------------------------------------------- | -------------------------------------- |
| License | Open source (AGPL-3.0) | Source-available (Elastic License 2.0) |
| Platforms | macOS, Linux, Windows | macOS only |
| Native mobile | iOS, Android | — |
| Login required | No | Yes (Superset account) |
| Pricing | Free | Free 1 seat, $20/seat/mo Pro |
| Per-agent native UI | Yes (modes, slash commands, file picker, diff viewer) | Terminal output |
| Split panes and tabs | Yes | Yes (terminals) |
| In-app browser | Yes | — |
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes |
| Git worktrees | Yes | Yes |
| Per-worktree dev server URLs | Yes | — |
| CLI | Run, `--host`, ls, send, schedule, loop | — |
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Conductor](/docs/alternatives/conductor), [Paseo vs OpenChamber](/docs/alternatives/openchamber), [Paseo vs Happy Coder](/docs/alternatives/happy-coder).

View File

@@ -1,18 +1,56 @@
import { existsSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
// In CI we often install a single workspace (e.g. server/relay/website). Only apply patches
// when the patched dependency is actually present.
const hasDraggableFlatlist = existsSync("node_modules/react-native-draggable-flatlist");
if (!hasDraggableFlatlist) {
const patchedPackages = [
{
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
patchPrefix: "react-native-draggable-flatlist+",
},
{
nodeModulesPath: "node_modules/react-native-gesture-handler",
patchPrefix: "react-native-gesture-handler+",
},
];
const installedPatchPrefixes = patchedPackages
.filter(({ nodeModulesPath }) => existsSync(nodeModulesPath))
.map(({ patchPrefix }) => patchPrefix);
if (!existsSync("patches") || installedPatchPrefixes.length === 0) {
process.exit(0);
}
const patchFilesToApply = readdirSync("patches").filter(
(file) =>
file.endsWith(".patch") &&
installedPatchPrefixes.some((patchPrefix) => file.startsWith(patchPrefix)),
);
if (patchFilesToApply.length === 0) {
process.exit(0);
}
const isWindows = process.platform === "win32";
const cmd = isWindows ? "patch-package.cmd" : "patch-package";
const result = spawnSync(cmd, [], {
shell: isWindows,
stdio: "inherit",
windowsHide: true,
});
const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}`);
mkdirSync(tempPatchDir, { recursive: true });
for (const patchFile of patchFilesToApply) {
copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile));
}
let result;
try {
result = spawnSync(cmd, ["--patch-dir", tempPatchDir], {
shell: isWindows,
stdio: "inherit",
windowsHide: true,
});
} finally {
rmSync(tempPatchDir, { recursive: true, force: true });
}
process.exit(result.status ?? 1);