mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63fe5dd0df | ||
|
|
1b9861846c | ||
|
|
68bce623c8 | ||
|
|
2072265b98 | ||
|
|
f7ef0e0b84 | ||
|
|
0419346d6a | ||
|
|
2866a12984 | ||
|
|
120f5d94a9 | ||
|
|
29ce6653fd | ||
|
|
028839b3cb | ||
|
|
82319f5805 | ||
|
|
69fc6fe754 | ||
|
|
1b2a28be47 | ||
|
|
665d9cedb5 | ||
|
|
0d0012959a | ||
|
|
77b92b58f7 | ||
|
|
d4ebf1815c |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
1388
CHANGELOG.md
1388
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-SRax+Frqf0OhDhfWICSlhOnHBGsoHiXQc6P+zo5VjN8=
|
||||
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=
|
||||
|
||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:
|
||||
|
||||
64
packages/app/e2e/workspace-pane-remount.spec.ts
Normal file
64
packages/app/e2e/workspace-pane-remount.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
const INTERACTIVE_TARGET_SELECTOR = [
|
||||
"a",
|
||||
"button",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[contenteditable='true']",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.78",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.78",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.docs-prose th,
|
||||
|
||||
60
patches/react-native-gesture-handler+2.28.0.patch
Normal file
60
patches/react-native-gesture-handler+2.28.0.patch
Normal 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);
|
||||
95
public-docs/alternatives/conductor.md
Normal file
95
public-docs/alternatives/conductor.md
Normal 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).
|
||||
97
public-docs/alternatives/happy-coder.md
Normal file
97
public-docs/alternatives/happy-coder.md
Normal 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).
|
||||
91
public-docs/alternatives/openchamber.md
Normal file
91
public-docs/alternatives/openchamber.md
Normal 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).
|
||||
114
public-docs/alternatives/superset.md
Normal file
114
public-docs/alternatives/superset.md
Normal 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).
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user