mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63fe5dd0df | ||
|
|
1b9861846c | ||
|
|
68bce623c8 | ||
|
|
2072265b98 | ||
|
|
f7ef0e0b84 | ||
|
|
0419346d6a | ||
|
|
2866a12984 | ||
|
|
120f5d94a9 | ||
|
|
29ce6653fd | ||
|
|
028839b3cb | ||
|
|
82319f5805 | ||
|
|
69fc6fe754 | ||
|
|
1b2a28be47 | ||
|
|
665d9cedb5 | ||
|
|
0d0012959a | ||
|
|
77b92b58f7 | ||
|
|
d4ebf1815c | ||
|
|
2f638fe6eb | ||
|
|
b8154aa72b | ||
|
|
a2d8ce07d6 | ||
|
|
721f1ee8d3 | ||
|
|
b55aa042d4 | ||
|
|
1f7fc232b3 | ||
|
|
338a41991d | ||
|
|
ee44b536f5 | ||
|
|
efc9c2d345 | ||
|
|
c4e45af565 | ||
|
|
e27734b218 | ||
|
|
686a25fb65 | ||
|
|
0e3a78b308 | ||
|
|
edb0ba888a | ||
|
|
00e7ac9ec8 | ||
|
|
b3c272f720 | ||
|
|
bb5c3dae00 | ||
|
|
ed7944cf9d | ||
|
|
91d257e103 | ||
|
|
e379707a68 | ||
|
|
f6afe0d864 | ||
|
|
952b58c0eb | ||
|
|
9324a5b67a | ||
|
|
ec646db844 | ||
|
|
1812b14898 | ||
|
|
1c38ffe0a9 | ||
|
|
f4a4e0c25c | ||
|
|
a7dd0da2aa | ||
|
|
618b4a30b1 | ||
|
|
4b02daed8a | ||
|
|
667f441cc0 | ||
|
|
5762055213 | ||
|
|
675817f0b2 | ||
|
|
35c582913d | ||
|
|
e309d82b41 | ||
|
|
5ea68dcdfa | ||
|
|
01eb1ad512 |
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -12,6 +12,11 @@ concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
# postinstall download from NuGet is large enough to make npm ci flaky.
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -99,8 +104,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -113,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:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
result
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
1374
CHANGELOG.md
1374
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -27,12 +27,12 @@ Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
@@ -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.
|
||||
|
||||
@@ -107,7 +107,7 @@ Enables remote access when the daemon is behind a firewall.
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
},
|
||||
app: {
|
||||
|
||||
@@ -16,6 +16,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
|
||||
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
|
||||
# different value.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
|
||||
# without this, the desktop drv produces a separately-named store path
|
||||
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
|
||||
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
|
||||
paseo,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -21,7 +22,8 @@ buildNpmPackage rec {
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
@@ -42,7 +44,7 @@ buildNpmPackage rec {
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit npmDepsHash;
|
||||
inherit (paseo) npmDeps;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
|
||||
@@ -135,8 +135,9 @@ in
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
NixOS profile, home-manager profile (`~/.nix-profile/bin` and
|
||||
`~/.local/state/nix/profile/bin`), and system paths so agents can use
|
||||
them without manually setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
@@ -221,17 +222,31 @@ in
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment (
|
||||
let
|
||||
# Match dataDir's convention. We can't read users.users.<name>.home
|
||||
# because the user may be managed outside NixOS.
|
||||
userHome = "/home/${cfg.user}";
|
||||
in {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). When the
|
||||
# daemon runs as a real user, also include home-manager profile paths
|
||||
# so user-installed CLIs (claude, opencode, codex, ...) are reachable
|
||||
# by agent processes the daemon spawns.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" (
|
||||
lib.optionals (cfg.user != "paseo") [
|
||||
"${userHome}/.nix-profile/bin"
|
||||
"${userHome}/.local/state/nix/profile/bin"
|
||||
]
|
||||
++ [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]
|
||||
));
|
||||
}
|
||||
) // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-1NkvtCT9TelnukukDD+jkIbwxLbWiNeMbXtJIzhTzv0=
|
||||
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=
|
||||
|
||||
@@ -88,47 +88,25 @@ buildNpmPackage rec {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
mkdir -p $out/lib/paseo
|
||||
node scripts/trace-daemon.mjs > daemon-files.txt
|
||||
|
||||
# Copy root package metadata
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
mkdir -p "$out/lib/paseo/$(dirname "$path")"
|
||||
cp -a "$path" "$out/lib/paseo/$path"
|
||||
done < daemon-files.txt
|
||||
|
||||
# Root package.json lets node resolve the workspace layout when the
|
||||
# CLI/server bin starts from $out.
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy server scripts (including supervisor-entrypoint) needed by CLI
|
||||
if [ -d packages/server/dist/scripts ]; then
|
||||
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
|
||||
410
package-lock.json
generated
410
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -22,6 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
@@ -7266,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",
|
||||
@@ -8583,6 +8584,102 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz",
|
||||
"integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3",
|
||||
"detect-libc": "^2.0.0",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"node-fetch": "^2.6.7",
|
||||
"nopt": "^8.0.0",
|
||||
"semver": "^7.5.3",
|
||||
"tar": "^7.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.3.tgz",
|
||||
@@ -11561,6 +11658,36 @@
|
||||
"integrity": "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
@@ -11572,7 +11699,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11585,7 +11713,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11598,7 +11727,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11611,7 +11741,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11624,7 +11755,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11637,7 +11769,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11650,7 +11783,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11663,7 +11797,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11676,7 +11811,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11689,7 +11825,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11702,7 +11839,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11715,7 +11853,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11728,7 +11867,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11741,7 +11881,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11754,7 +11895,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11767,7 +11909,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11780,7 +11923,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11793,7 +11937,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11806,7 +11951,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11819,7 +11965,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11832,7 +11979,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11845,7 +11993,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11858,7 +12007,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11871,7 +12021,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11884,7 +12035,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
@@ -15288,6 +15440,124 @@
|
||||
"wonka": "^6.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz",
|
||||
"integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^2.0.0",
|
||||
"@rollup/pluginutils": "^5.1.3",
|
||||
"acorn": "^8.6.0",
|
||||
"acorn-import-attributes": "^1.9.5",
|
||||
"async-sema": "^3.1.1",
|
||||
"bindings": "^1.4.0",
|
||||
"estree-walker": "2.0.2",
|
||||
"glob": "^13.0.0",
|
||||
"graceful-fs": "^4.2.9",
|
||||
"node-gyp-build": "^4.2.2",
|
||||
"picomatch": "^4.0.2",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nft": "out/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/nft/node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/oidc": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz",
|
||||
@@ -15665,6 +15935,16 @@
|
||||
"acorn-walk": "^8.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-import-attributes": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
|
||||
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
||||
@@ -16473,6 +16753,13 @@
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-sema": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz",
|
||||
"integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -16945,6 +17232,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/blake3-wasm": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
|
||||
@@ -18252,6 +18549,16 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -23955,6 +24262,13 @@
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
@@ -30824,6 +31138,18 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -38964,7 +39290,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38972,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",
|
||||
@@ -39091,10 +39417,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.76",
|
||||
"@getpaseo/server": "0.1.78",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39137,7 +39463,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -39186,7 +39512,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -39222,7 +39548,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -39248,7 +39574,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39263,12 +39589,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/highlight": "0.1.76",
|
||||
"@getpaseo/relay": "0.1.76",
|
||||
"@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",
|
||||
@@ -39811,7 +40137,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.76",
|
||||
"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.76",
|
||||
"version": "0.1.78",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -93,6 +93,7 @@
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
needs: [build_ios, submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
@@ -49,4 +49,7 @@ jobs:
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: bundle exec fastlane ios submit_review
|
||||
run: |
|
||||
export APP_VERSION="${{ needs.build_ios.outputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ needs.build_ios.outputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
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: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
|
||||
app_build_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
|
||||
|
||||
jobs:
|
||||
submit_ios_for_review:
|
||||
@@ -18,4 +27,7 @@ jobs:
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: bundle exec fastlane ios submit_review
|
||||
run: |
|
||||
export APP_VERSION="${{ inputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "fastlane", "~> 2.234"
|
||||
|
||||
205
packages/app/e2e/client-slash-commands.spec.ts
Normal file
205
packages/app/e2e/client-slash-commands.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
expectSessionRowArchived,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
interface SlashCommandScenario {
|
||||
agent: { id: string };
|
||||
client: TerminalPerfDaemonClient;
|
||||
cwd: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function withOpenReadyMockAgent(
|
||||
page: Page,
|
||||
input: {
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
run: (scenario: SlashCommandScenario) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const repo = await createTempGitRepo("client-slash-command-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await createReadyMockAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modeId: input.modeId,
|
||||
});
|
||||
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await run({ agent, client, cwd: repo.path, title: input.title });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReadyMockAgent(
|
||||
client: TerminalPerfDaemonClient,
|
||||
input: {
|
||||
cwd: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
modeId: input.modeId ?? "load-test",
|
||||
model: input.model ?? "ten-second-stream",
|
||||
initialPrompt: "Prepare a client slash command test agent.",
|
||||
});
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
async function openActiveAgentTab(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceTabVisible(page, input.agentId);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(command);
|
||||
await expect(input).toHaveValue(command);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function selectClientSlashCommand(page: Page, query: string, label: string): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function expectAgentArchivedInSessions(page: Page, title: string): Promise<void> {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, title);
|
||||
}
|
||||
|
||||
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
|
||||
await expectComposerVisible(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Select model (Ten second stream)" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
|
||||
await submitMessage(page, REPLACEMENT_PROMPT);
|
||||
}
|
||||
|
||||
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
|
||||
let newAgentId: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const ids = await page
|
||||
.locator('[data-testid^="workspace-tab-agent_"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.flatMap((node) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
const testId = node.getAttribute("data-testid") ?? "";
|
||||
if (!testId.startsWith("workspace-tab-agent_")) {
|
||||
return [];
|
||||
}
|
||||
if (node.offsetParent === null) {
|
||||
return [];
|
||||
}
|
||||
return [testId.slice("workspace-tab-agent_".length)];
|
||||
}),
|
||||
);
|
||||
newAgentId = ids.find((id) => id !== oldAgentId) ?? null;
|
||||
return newAgentId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
if (!newAgentId) {
|
||||
throw new Error("Replacement agent was not created.");
|
||||
}
|
||||
return newAgentId;
|
||||
}
|
||||
|
||||
test.describe("Client slash commands", () => {
|
||||
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
|
||||
await runClientSlashCommand(page, "/quit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
});
|
||||
});
|
||||
|
||||
test("slash quit selected from autocomplete archives immediately", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash quit autocomplete e2e" },
|
||||
async ({ agent, title }) => {
|
||||
await selectClientSlashCommand(page, "/qu", "/quit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("slash clear replaces the active agent with a matching draft", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
||||
async ({ agent, title }) => {
|
||||
await runClientSlashCommand(page, "/clear");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectReplacementDraftMatchesPreviousSetup(page);
|
||||
await createAgentFromReplacementDraft(page);
|
||||
await waitForReplacementAgentId(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,10 @@ export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
|
||||
const closeButton = page.getByTestId("sidebar-close");
|
||||
await expect(closeButton).toBeInViewport({ timeout: 5_000 });
|
||||
await closeButton.click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface TerminalPerfDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,6 @@ platform :ios do
|
||||
key_filepath: ENV.fetch("ASC_KEY_P8"),
|
||||
)
|
||||
|
||||
build_number = latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
)
|
||||
|
||||
require "spaceship"
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
@@ -25,30 +20,58 @@ platform :ios do
|
||||
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
|
||||
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
|
||||
|
||||
# The release workflow forwards the marketing version and CFBundleVersion of
|
||||
# the binary it just built. Use them to identify the exact build, since
|
||||
# CFBundleVersion is not unique across marketing versions in this project
|
||||
# (the production profile resets it per app_version). The resubmit workflow
|
||||
# leaves these unset and falls back to "most recently uploaded iOS build".
|
||||
target_version = ENV["APP_VERSION"].to_s.strip
|
||||
target_build = ENV["APP_BUILD_VERSION"].to_s.strip
|
||||
target_version = nil if target_version.empty?
|
||||
target_build = nil if target_build.empty?
|
||||
|
||||
if target_version
|
||||
UI.message("Targeting App Store version #{target_version}#{target_build ? " build #{target_build}" : ""} from workflow input")
|
||||
else
|
||||
UI.message("No APP_VERSION provided; selecting the most recently uploaded iOS build")
|
||||
end
|
||||
|
||||
valid_build = nil
|
||||
deadline = Time.now + (30 * 60)
|
||||
loop do
|
||||
builds = Spaceship::ConnectAPI::Build.all(
|
||||
app_id: app.id,
|
||||
build_number: build_number.to_s,
|
||||
)
|
||||
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||
state = build&.processing_state
|
||||
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||
sort: "-uploadedDate",
|
||||
limit: 100,
|
||||
).select { |b| b.pre_release_version&.platform == "IOS" }
|
||||
builds = builds.select { |b| b.pre_release_version&.version == target_version } if target_version
|
||||
builds = builds.select { |b| b.version == target_build } if target_build
|
||||
|
||||
build = builds.first
|
||||
if build.nil?
|
||||
UI.user_error!("Timed out waiting for build to appear on App Store Connect.") if Time.now > deadline
|
||||
UI.message("No matching iOS build visible on App Store Connect yet; waiting...")
|
||||
sleep(30)
|
||||
next
|
||||
end
|
||||
|
||||
state = build.processing_state
|
||||
UI.message("Build #{build.pre_release_version.version} (#{build.version}) processing state: #{state || 'unknown'}")
|
||||
if state == "VALID"
|
||||
valid_build = build
|
||||
break
|
||||
end
|
||||
if state == "INVALID" || state == "FAILED"
|
||||
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||
UI.user_error!("Build #{build.version} failed App Store processing (state: #{state}).")
|
||||
end
|
||||
if Time.now > deadline
|
||||
UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
|
||||
UI.user_error!("Timed out waiting for build #{build.version} to finish processing on App Store Connect.")
|
||||
end
|
||||
sleep(30)
|
||||
end
|
||||
|
||||
app_version = valid_build.pre_release_version.version
|
||||
build_number = valid_build.version
|
||||
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
|
||||
|
||||
# Ensure an editable App Store version exists for this marketing version.
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.78",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"start": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
@@ -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",
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -375,6 +376,8 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||
const flexStyle = { flex: 1 } as const;
|
||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
@@ -406,6 +409,7 @@ function AppContainer({
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
useCompactWebViewportZoomLock(isCompactLayout);
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const pathname = usePathname();
|
||||
const activeServerId = useMemo(
|
||||
@@ -498,6 +502,7 @@ function MobileGestureWrapper({
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
@@ -516,6 +521,7 @@ function MobileGestureWrapper({
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch) {
|
||||
touchStartX.value = touch.absoluteX;
|
||||
touchStartY.value = touch.absoluteY;
|
||||
}
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
@@ -523,13 +529,31 @@ function MobileGestureWrapper({
|
||||
if (!touch || event.numberOfTouches !== 1) return;
|
||||
|
||||
const deltaX = touch.absoluteX - touchStartX.value;
|
||||
const deltaY = touch.absoluteY - touchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX > 15) {
|
||||
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX > 15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
@@ -571,11 +595,12 @@ function MobileGestureWrapper({
|
||||
openGestureRef,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
|
||||
13
packages/app/src/assistant-file-links/index.ts
Normal file
13
packages/app/src/assistant-file-links/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AssistantInlineCodePathLink,
|
||||
AssistantInlinePathLink,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkResolver } from "./use-resolver";
|
||||
346
packages/app/src/assistant-file-links/link.tsx
Normal file
346
packages/app/src/assistant-file-links/link.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
|
||||
interface AssistantInlinePathLinkProps {
|
||||
content: string;
|
||||
parsed: InlinePathTarget;
|
||||
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
workspaceRoot?: string;
|
||||
style: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
export function AssistantInlinePathLink({
|
||||
content,
|
||||
parsed,
|
||||
onPress,
|
||||
workspaceRoot,
|
||||
style,
|
||||
}: AssistantInlinePathLinkProps) {
|
||||
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
|
||||
const handleAnchorClickCapture = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onPress(parsed, "side");
|
||||
},
|
||||
[onPress, parsed],
|
||||
);
|
||||
|
||||
if (!isNative) {
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
|
||||
<a
|
||||
href={parsed.path}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
|
||||
{content}
|
||||
</Text>
|
||||
</a>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownLink({
|
||||
source,
|
||||
style,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const href = source.href;
|
||||
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
|
||||
const handleAnchorClickCapture = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onPress(source, "side");
|
||||
},
|
||||
[onPress, source],
|
||||
);
|
||||
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
||||
const handleHoverIn = useCallback(() => {
|
||||
setHovered(true);
|
||||
handlePrefetch();
|
||||
}, [handlePrefetch]);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
const tooltipFilePath = useMemo(
|
||||
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
|
||||
[source.href, workspaceRoot],
|
||||
);
|
||||
if (isNative) {
|
||||
return (
|
||||
<Text accessibilityRole="link" onPress={handlePress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
href={href}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onFocus={handlePrefetch}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (tooltipFilePath) {
|
||||
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
return anchor;
|
||||
}
|
||||
|
||||
interface AssistantMarkdownCodeLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownCodeLink({
|
||||
source,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
children,
|
||||
}: AssistantMarkdownCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, codeInlineStyle, linkStyle],
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink
|
||||
source={source}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{children}
|
||||
</AssistantMarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function AssistantInlineCodePathLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
}: AssistantInlineCodePathLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantMarkdownCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkTooltipFilePath(
|
||||
href: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const classification = classifyAssistantFileLink(href, { workspaceRoot });
|
||||
if (classification?.kind !== "directFile") {
|
||||
return null;
|
||||
}
|
||||
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
||||
// the tooltip wrapper from breaking inline link flow.
|
||||
display: "inline-flex" as ViewStyle["display"],
|
||||
};
|
||||
|
||||
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
||||
|
||||
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
|
||||
if (!isWeb) {
|
||||
return children;
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
<View style={styles.tooltipHintRow}>
|
||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||
<Text selectable={false} style={styles.tooltipHintText}>
|
||||
click for side pane
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const LINK_ANCHOR_STYLE: CSSProperties = {
|
||||
display: "contents",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tooltipBody: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipHintRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipHintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
parseAssistantFileLink,
|
||||
parseFileProtocolUrl,
|
||||
parseInlinePathToken,
|
||||
} from "./inline-path";
|
||||
} from "./parse";
|
||||
|
||||
describe("parseInlinePathToken", () => {
|
||||
it("returns null for plain paths without a line number", () => {
|
||||
@@ -31,6 +31,33 @@ describe("parseInlinePathToken", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename:line:column as a line target", () => {
|
||||
expect(parseInlinePathToken("src/app.ts:12:4")).toEqual({
|
||||
raw: "src/app.ts:12:4",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename(line,column) as a line target", () => {
|
||||
expect(parseInlinePathToken("src/app.ts(12,4)")).toEqual({
|
||||
raw: "src/app.ts(12,4)",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses filename lines lineStart-lineEnd", () => {
|
||||
expect(parseInlinePathToken("src/app.ts lines 12-20")).toEqual({
|
||||
raw: "src/app.ts lines 12-20",
|
||||
path: "src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects range-only :line tokens", () => {
|
||||
expect(parseInlinePathToken(":12")).toBeNull();
|
||||
expect(parseInlinePathToken(":12-20")).toBeNull();
|
||||
@@ -47,6 +74,15 @@ describe("parseFileProtocolUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file URLs with line-column fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81C5-L83C2")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx#L81C5-L83C2",
|
||||
path: "/Users/test/project/src/app.tsx",
|
||||
lineStart: 81,
|
||||
lineEnd: 83,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file URLs without line fragments", () => {
|
||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
|
||||
raw: "file:///Users/test/project/src/app.tsx",
|
||||
@@ -148,6 +184,33 @@ describe("classifyAssistantFileLink", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not classify plain inline code words or identifiers as file candidates", () => {
|
||||
for (const value of [
|
||||
"main",
|
||||
"origin/main",
|
||||
"1f7fc232b",
|
||||
"25994904967",
|
||||
"babysit main 1f7fc232b",
|
||||
]) {
|
||||
expect(
|
||||
classifyAssistantFileLink(value, {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not classify shell commands containing path arguments as file candidates", () => {
|
||||
expect(
|
||||
classifyAssistantFileLink(
|
||||
"npm run lint -- packages/app/src/stores/workspace-layout-actions.ts packages/app/src/stores/workspace-layout-store.ts packages/app/src/screens/workspace/workspace-screen.tsx",
|
||||
{
|
||||
workspaceRoot: "/Users/test/project",
|
||||
},
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAssistantFileLink", () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isAbsolutePath } from "./path";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export interface InlinePathTarget {
|
||||
raw: string;
|
||||
@@ -8,7 +8,10 @@ export interface InlinePathTarget {
|
||||
}
|
||||
|
||||
const FILE_PROTOCOL = "file:";
|
||||
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
|
||||
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)?$/i;
|
||||
const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/;
|
||||
const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/;
|
||||
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
|
||||
const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||
"astro",
|
||||
"bash",
|
||||
@@ -142,7 +145,10 @@ export function parseInlinePathToken(value: string): InlinePathTarget | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+?):([0-9]+)(?:-([0-9]+))?$/);
|
||||
const match =
|
||||
trimmed.match(INLINE_COLON_LINE_SUFFIX) ??
|
||||
trimmed.match(INLINE_PAREN_LINE_SUFFIX) ??
|
||||
trimmed.match(INLINE_WORD_LINE_SUFFIX);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -260,6 +266,10 @@ export function classifyAssistantFileLink(
|
||||
};
|
||||
}
|
||||
|
||||
if (/\s/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = parseAssistantFileLink(trimmed, options);
|
||||
if (!target) {
|
||||
return null;
|
||||
@@ -363,7 +373,12 @@ export function parseAssistantFileLink(
|
||||
|
||||
export function isFileLookingAssistantToken(value: string): boolean {
|
||||
const normalized = normalizePathToken(value);
|
||||
if (!normalized || normalized.includes("?") || normalized.includes("://")) {
|
||||
if (
|
||||
!normalized ||
|
||||
/\s/.test(normalized) ||
|
||||
normalized.includes("?") ||
|
||||
normalized.includes("://")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -580,19 +595,28 @@ function isPlausibleAssistantLocalPath(pathValue: string): boolean {
|
||||
}
|
||||
|
||||
if (segments.length > 1) {
|
||||
return !isDomainLikePathSegment(firstSegment);
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
|
||||
}
|
||||
|
||||
if (firstSegment.startsWith(".") && firstSegment.length > 1) {
|
||||
return isPlausibleAssistantFileName(firstSegment);
|
||||
}
|
||||
|
||||
function isPlausibleAssistantFileName(fileName: string | undefined): boolean {
|
||||
if (!fileName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileName.startsWith(".") && fileName.length > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastDot = firstSegment.lastIndexOf(".");
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot < 0) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = firstSegment.slice(lastDot + 1).toLowerCase();
|
||||
const extension = fileName.slice(lastDot + 1).toLowerCase();
|
||||
return ASSISTANT_FILE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import {
|
||||
createAssistantFileLinkResolver,
|
||||
getAssistantFileLinkToken,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
type DirectorySuggestionResult,
|
||||
} from "./assistant-file-link-resolver";
|
||||
} from "./resolver";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
|
||||
const CONTEXT: AssistantFileLinkContext = {
|
||||
serverId: "server-1",
|
||||
@@ -28,6 +31,55 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
interface DirectorySearch {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
class FakeWorkspaceFiles {
|
||||
readonly searches: DirectorySearch[] = [];
|
||||
readonly openedFiles: OpenedFile[] = [];
|
||||
readonly unresolvedTokens: string[] = [];
|
||||
|
||||
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
|
||||
|
||||
createResolver() {
|
||||
return createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: this.getDirectorySuggestions,
|
||||
openWorkspaceFile: this.openWorkspaceFile,
|
||||
openExternalUrl: async () => {},
|
||||
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
|
||||
});
|
||||
}
|
||||
|
||||
private getDirectorySuggestions = async (
|
||||
search: DirectorySearch,
|
||||
): Promise<DirectorySuggestionResult> => {
|
||||
this.searches.push(search);
|
||||
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
|
||||
};
|
||||
|
||||
private openWorkspaceFile = (
|
||||
target: InlinePathTarget,
|
||||
disposition: OpenFileDisposition,
|
||||
): void => {
|
||||
this.openedFiles.push({ target, disposition });
|
||||
};
|
||||
|
||||
private onUnresolvedFileCandidate = (token: string): void => {
|
||||
this.unresolvedTokens.push(token);
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant file link resolver", () => {
|
||||
it("dedupes in-flight prefetches and serves the click from cache", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
@@ -46,7 +98,7 @@ describe("assistant file link resolver", () => {
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
]);
|
||||
const result = await resolver.open({ context: CONTEXT, source });
|
||||
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
@@ -54,14 +106,18 @@ describe("assistant file link resolver", () => {
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/src/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/src/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
@@ -78,19 +134,22 @@ describe("assistant file link resolver", () => {
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
|
||||
|
||||
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
||||
const opened = resolver.open({ context: CONTEXT, source });
|
||||
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
|
||||
await prefetch;
|
||||
const result = await opened;
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
@@ -109,19 +168,22 @@ describe("assistant file link resolver", () => {
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
|
||||
const openResult = await resolver.open({ context: CONTEXT, source });
|
||||
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(openResult.opened).toBe(true);
|
||||
});
|
||||
@@ -142,8 +204,8 @@ describe("assistant file link resolver", () => {
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const first = await resolver.open({ context: CONTEXT, source });
|
||||
const second = await resolver.open({ context: CONTEXT, source });
|
||||
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
@@ -152,12 +214,15 @@ describe("assistant file link resolver", () => {
|
||||
});
|
||||
expect(second.opened).toBe(true);
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -175,19 +240,23 @@ describe("assistant file link resolver", () => {
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await resolver.open({ context: CONTEXT, source });
|
||||
await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
await resolver.open({
|
||||
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
|
||||
source,
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenLastCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/other/two/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
|
||||
{
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/other/two/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply stale async results after the active context changes", async () => {
|
||||
@@ -204,6 +273,7 @@ describe("assistant file link resolver", () => {
|
||||
const opened = resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
isCurrent = false;
|
||||
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
|
||||
@@ -226,56 +296,145 @@ describe("assistant file link resolver", () => {
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves inline-code basename line refs through directory suggestions", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([
|
||||
{ path: "packages/server/src/server/workspace-git-service.ts", kind: "file" },
|
||||
]),
|
||||
);
|
||||
it("preserves direct workspace file line ranges", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx:33-40" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx:33-40",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: 40,
|
||||
},
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens basename line refs when the daemon returns that exact filename", async () => {
|
||||
const workspaceFiles = new FakeWorkspaceFiles({
|
||||
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
|
||||
});
|
||||
const resolver = workspaceFiles.createResolver();
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "workspace-git-service.ts:1553",
|
||||
text: "workspace-git-service.ts:1553",
|
||||
href: "file.ts:12",
|
||||
text: "file.ts:12",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
query: "workspace-git-service.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "workspace-git-service.ts:1553",
|
||||
path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts",
|
||||
lineStart: 1553,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(workspaceFiles.searches).toEqual([
|
||||
{
|
||||
query: "file.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(workspaceFiles.openedFiles).toEqual([
|
||||
{
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/packages/app/src/file.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
},
|
||||
]);
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
|
||||
const workspaceFiles = new FakeWorkspaceFiles({});
|
||||
const resolver = workspaceFiles.createResolver();
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "src/file.ts",
|
||||
text: "src/file.ts",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(workspaceFiles.searches).toEqual([
|
||||
{
|
||||
query: "src/file.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(workspaceFiles.openedFiles).toEqual([]);
|
||||
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "src/file.ts",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes side open disposition to workspace file links", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
disposition: "side",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
{
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
"side",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit external URLs external", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
@@ -287,6 +446,7 @@ describe("assistant file link resolver", () => {
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
|
||||
@@ -309,6 +469,7 @@ describe("assistant file link resolver", () => {
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
@@ -332,6 +493,7 @@ describe("assistant file link resolver", () => {
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
@@ -361,6 +523,7 @@ describe("assistant file link resolver", () => {
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
@@ -393,6 +556,7 @@ describe("assistant file link resolver", () => {
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
disposition: "main",
|
||||
});
|
||||
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
isFileLookingAssistantToken,
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./inline-path";
|
||||
} from "./parse";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
serverId?: string;
|
||||
@@ -34,24 +35,29 @@ export interface AssistantFileLinkResolverDependencies {
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
openWorkspaceFile: (target: InlinePathTarget) => void;
|
||||
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
openExternalUrl: (url: string) => void | Promise<void>;
|
||||
onUnresolvedFileCandidate?: (token: string) => void;
|
||||
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolver {
|
||||
prefetch(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink>;
|
||||
open(input: AssistantFileLinkInteraction): Promise<AssistantFileLinkOpenResult>;
|
||||
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
|
||||
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkInteraction {
|
||||
export interface AssistantFileLinkPrefetchInput {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
export type ResolvedAssistantFileLink =
|
||||
| {
|
||||
kind: "external";
|
||||
@@ -86,7 +92,9 @@ export function createAssistantFileLinkResolver(
|
||||
const cache = new Map<string, CachedAssistantFileLink>();
|
||||
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
||||
|
||||
async function resolve(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink> {
|
||||
async function resolve(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): Promise<ResolvedAssistantFileLink> {
|
||||
const parsed = parseInteraction(input);
|
||||
if (!parsed) {
|
||||
return { kind: "ignored" };
|
||||
@@ -96,7 +104,15 @@ export function createAssistantFileLinkResolver(
|
||||
return { kind: "external", url: parsed.classification.raw };
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "directFile") {
|
||||
if (
|
||||
parsed.classification.kind === "directFile" &&
|
||||
!shouldResolveDirectFileThroughSuggestions({
|
||||
context: input.context,
|
||||
source: input.source,
|
||||
token: parsed.token,
|
||||
target: parsed.classification.target,
|
||||
})
|
||||
) {
|
||||
return { kind: "file", target: parsed.classification.target };
|
||||
}
|
||||
|
||||
@@ -144,7 +160,7 @@ export function createAssistantFileLinkResolver(
|
||||
}
|
||||
|
||||
if (resolved.kind === "file") {
|
||||
dependencies.openWorkspaceFile(resolved.target);
|
||||
dependencies.openWorkspaceFile(resolved.target, input.disposition);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
|
||||
@@ -174,7 +190,7 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri
|
||||
}
|
||||
|
||||
function parseInteraction(
|
||||
input: AssistantFileLinkInteraction,
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): ParsedAssistantFileLinkInteraction | null {
|
||||
const token = getAssistantFileLinkToken(input.source).trim();
|
||||
if (!token) {
|
||||
@@ -208,6 +224,7 @@ async function resolveAmbiguousCandidate(input: {
|
||||
cwd: workspaceRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
@@ -236,6 +253,38 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
|
||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||
}
|
||||
|
||||
function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
}): boolean {
|
||||
if (input.source.sourceType !== "inline-code") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAbsoluteInlineCodeToken(input.token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceRoot = input.context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = input.target.path.replace(/\\/g, "/");
|
||||
return normalizedPath.startsWith(`${normalizedRoot}/`);
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
}
|
||||
|
||||
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
|
||||
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
|
||||
}
|
||||
@@ -4,22 +4,24 @@ import type { ToastApi } from "@/components/toast-host";
|
||||
import {
|
||||
createAssistantFileLinkResolver,
|
||||
type AssistantFileLinkContext,
|
||||
type AssistantFileLinkInteraction,
|
||||
} from "@/utils/assistant-file-link-resolver";
|
||||
import type { InlinePathTarget } from "@/utils/inline-path";
|
||||
type AssistantFileLinkOpenInput,
|
||||
type AssistantFileLinkPrefetchInput,
|
||||
} from "./resolver";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
export interface UseAssistantFileLinkResolverOptions {
|
||||
client?: DaemonClient | null;
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
onOpenWorkspaceFile?: (target: InlinePathTarget) => void;
|
||||
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
toast?: ToastApi | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkActions {
|
||||
prefetch(input: Omit<AssistantFileLinkInteraction, "context">): void;
|
||||
open(input: Omit<AssistantFileLinkInteraction, "context">): void;
|
||||
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
|
||||
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkResolver({
|
||||
@@ -53,8 +55,8 @@ export function useAssistantFileLinkResolver({
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
openWorkspaceFile(target) {
|
||||
onOpenWorkspaceFile?.(target);
|
||||
openWorkspaceFile(target, disposition) {
|
||||
onOpenWorkspaceFile?.(target, disposition);
|
||||
},
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate(token) {
|
||||
130
packages/app/src/client-slash-commands/index.test.ts
Normal file
130
packages/app/src/client-slash-commands/index.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLIENT_SLASH_COMMANDS,
|
||||
buildDraftAgentSetup,
|
||||
resolveClientSlashCommand,
|
||||
} from "@/client-slash-commands";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
const now = new Date("2026-05-15T00:00:00.000Z");
|
||||
return {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: now,
|
||||
lastActivityAt: now,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "mode-current",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: "session-1",
|
||||
model: "runtime-model",
|
||||
modeId: "runtime-mode",
|
||||
thinkingOptionId: "runtime-thinking",
|
||||
},
|
||||
title: "Agent",
|
||||
cwd: "/repo",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
features: [
|
||||
{ type: "toggle", id: "web-search", label: "Web search", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "effort",
|
||||
label: "Effort",
|
||||
value: "high",
|
||||
options: [{ id: "high", label: "High" }],
|
||||
},
|
||||
],
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveClientSlashCommand", () => {
|
||||
it("declares the exact client commands that execute immediately", () => {
|
||||
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
|
||||
["quit", "immediate"],
|
||||
["exit", "immediate"],
|
||||
["q", "immediate"],
|
||||
["clear", "immediate"],
|
||||
["new", "immediate"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves exact client commands after trimming", () => {
|
||||
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
});
|
||||
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
|
||||
"archive-agent",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
|
||||
"archive-agent",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
|
||||
"replace-agent-with-draft",
|
||||
);
|
||||
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
|
||||
"replace-agent-with-draft",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {
|
||||
expect(resolveClientSlashCommand({ text: "/clear now", hasAttachments: false })).toBeNull();
|
||||
expect(resolveClientSlashCommand({ text: "/quit now", hasAttachments: false })).toBeNull();
|
||||
expect(
|
||||
resolveClientSlashCommand({ text: "/provider-command", hasAttachments: false }),
|
||||
).toBeNull();
|
||||
expect(resolveClientSlashCommand({ text: "hello /quit", hasAttachments: false })).toBeNull();
|
||||
expect(resolveClientSlashCommand({ text: "/quit", hasAttachments: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftAgentSetup", () => {
|
||||
it("builds draft setup from the active agent snapshot", () => {
|
||||
expect(buildDraftAgentSetup(createAgent())).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "mode-current",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: {
|
||||
"web-search": true,
|
||||
effort: "high",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime model setup when top-level fields are absent", () => {
|
||||
expect(
|
||||
buildDraftAgentSetup(
|
||||
createAgent({
|
||||
currentModeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
modeId: "runtime-mode",
|
||||
model: "runtime-model",
|
||||
thinkingOptionId: "runtime-thinking",
|
||||
});
|
||||
});
|
||||
});
|
||||
90
packages/app/src/client-slash-commands/index.ts
Normal file
90
packages/app/src/client-slash-commands/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||
|
||||
export interface ClientSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
kind: ClientSlashCommandKind;
|
||||
execution: ClientSlashCommandExecution;
|
||||
}
|
||||
|
||||
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
{
|
||||
name: "quit",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "exit",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "q",
|
||||
description: "Archive the current agent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
|
||||
|
||||
export function resolveClientSlashCommand(input: {
|
||||
text: string;
|
||||
hasAttachments: boolean;
|
||||
}): ClientSlashCommand | null {
|
||||
if (input.hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = input.text.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandName = trimmed.slice(1);
|
||||
if (!commandName || /\s/.test(commandName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return COMMAND_BY_NAME.get(commandName) ?? null;
|
||||
}
|
||||
|
||||
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
@@ -12,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>
|
||||
|
||||
@@ -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,
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -199,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,
|
||||
@@ -258,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,
|
||||
@@ -420,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: {
|
||||
@@ -492,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);
|
||||
@@ -510,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,
|
||||
@@ -570,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) =>
|
||||
@@ -643,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) => {
|
||||
@@ -668,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}
|
||||
@@ -754,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}
|
||||
@@ -781,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>
|
||||
@@ -864,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;
|
||||
@@ -922,6 +899,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
handleModeOpenChange,
|
||||
handleOpenChange,
|
||||
renderModeOption,
|
||||
renderThinkingOption,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -1017,6 +995,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
onOpenChange={handleThinkingOpenChange}
|
||||
anchorRef={thinkingAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
renderOption={renderThinkingOption}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1076,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;
|
||||
@@ -1099,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,
|
||||
@@ -1146,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}
|
||||
@@ -1539,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1593,40 +1635,8 @@ function ModeComboboxOption({
|
||||
);
|
||||
}
|
||||
|
||||
function ModeMenuItem({
|
||||
mode,
|
||||
provider,
|
||||
providerDefinitions,
|
||||
selected,
|
||||
onSelectMode,
|
||||
}: {
|
||||
mode: StatusOption;
|
||||
provider: string;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selected: boolean;
|
||||
onSelectMode?: (modeId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelectMode?.(mode.id);
|
||||
}, [mode.id, onSelectMode]);
|
||||
|
||||
const leadingIcon = useMemo(
|
||||
() => <Icon size={16} color={theme.colors.foreground} />,
|
||||
[Icon, theme.colors.foreground],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leadingIcon}>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MODES: AgentMode[] = [];
|
||||
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
|
||||
|
||||
export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
agentId,
|
||||
@@ -1875,6 +1885,7 @@ export function DraftAgentStatusBar({
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
@@ -1922,7 +1933,7 @@ export function DraftAgentStatusBar({
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
if (platformIsWeb) {
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
@@ -2036,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,
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import React, { memo, useCallback, useEffect, useMemo, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
|
||||
import { SyncedLoader } from "./synced-loader";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
|
||||
@@ -130,63 +127,10 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = 0;
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: WORKING_INDICATOR_CYCLE_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
};
|
||||
}, [progress]);
|
||||
|
||||
const translateDistance = -2;
|
||||
const dotOneStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
|
||||
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
|
||||
const dotThreeCombinedStyle = useMemo(
|
||||
() => [stylesheet.workingDot, dotThreeStyle],
|
||||
[dotThreeStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingDotsRow}>
|
||||
<Animated.View style={dotOneCombinedStyle} />
|
||||
<Animated.View style={dotTwoCombinedStyle} />
|
||||
<Animated.View style={dotThreeCombinedStyle} />
|
||||
<View style={stylesheet.workingLoader}>
|
||||
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
|
||||
</View>
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
@@ -272,16 +216,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
workingDotsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
workingDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
workingLoader: {
|
||||
marginLeft: -2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -74,10 +74,15 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { normalizeInlinePathTarget } from "@/assistant-file-links";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -242,7 +247,7 @@ export interface AgentStreamViewProps {
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
@@ -302,11 +307,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||
filePath: string;
|
||||
}) {
|
||||
onOpenWorkspaceFile?.(input);
|
||||
});
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -323,7 +323,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}, [agentId]);
|
||||
|
||||
const handleInlinePathPress = useCallback(
|
||||
(target: InlinePathTarget) => {
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (!target.path) {
|
||||
return;
|
||||
}
|
||||
@@ -334,8 +334,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOpenWorkspaceFile) {
|
||||
openWorkspaceFile({ filePath: normalized.file });
|
||||
onOpenWorkspaceFile({
|
||||
location,
|
||||
disposition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -374,14 +386,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
setExplorerTabForCheckout,
|
||||
openWorkspaceFile,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToolCallOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath });
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
},
|
||||
[handleInlinePathPress],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -98,13 +98,19 @@ export function useComposerHeightMirror({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [value, measure]);
|
||||
}, [maxHeight, minHeight, value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || !(source instanceof HTMLElement)) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
let previousWidth = source.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = source.clientWidth;
|
||||
if (Math.abs(nextWidth - previousWidth) < 1) return;
|
||||
previousWidth = nextWidth;
|
||||
measure();
|
||||
});
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
|
||||
@@ -99,6 +99,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
@@ -607,6 +608,7 @@ interface ComposerProps {
|
||||
serverId: string;
|
||||
isPaneFocused: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
|
||||
hasExternalContent?: boolean;
|
||||
/** When true, the composer can submit even with no text or attachments. */
|
||||
@@ -739,6 +741,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
isAgentRunning: boolean;
|
||||
hasSendableContent: boolean;
|
||||
isProcessing: boolean;
|
||||
isCompact: boolean;
|
||||
cancelButton: ReactElement;
|
||||
}
|
||||
|
||||
@@ -748,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 (
|
||||
@@ -807,6 +812,7 @@ export function Composer({
|
||||
serverId,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
onClientSlashCommand,
|
||||
hasExternalContent = false,
|
||||
allowEmptySubmit = false,
|
||||
submitButtonAccessibilityLabel,
|
||||
@@ -907,6 +913,41 @@ export function Composer({
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
|
||||
const runClientSlashCommand = useCallback(
|
||||
(command: ClientSlashCommand): boolean => {
|
||||
if (command.execution !== "immediate" || !onClientSlashCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blurOnSubmit) {
|
||||
messageInputRef.current?.blur();
|
||||
}
|
||||
clearDraft("sent");
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
resetSuppression();
|
||||
setSendError(null);
|
||||
setIsProcessing(true);
|
||||
void onClientSlashCommand(command)
|
||||
.catch((error) => {
|
||||
console.error("[Composer] Failed to run client slash command:", error);
|
||||
setSendError(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsProcessing(false);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[
|
||||
blurOnSubmit,
|
||||
clearDraft,
|
||||
onClientSlashCommand,
|
||||
resetSuppression,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
],
|
||||
);
|
||||
|
||||
const autocomplete = useAgentAutocomplete({
|
||||
userInput,
|
||||
cursorIndex,
|
||||
@@ -914,6 +955,8 @@ export function Composer({
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig: commandDraftConfig,
|
||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||
onClientSlashCommand: runClientSlashCommand,
|
||||
onAutocompleteApplied: () => {
|
||||
messageInputRef.current?.focus();
|
||||
},
|
||||
@@ -1109,16 +1152,27 @@ export function Composer({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(payload: MessagePayload) => {
|
||||
const outgoingAttachments = buildOutgoingAttachments(attachments);
|
||||
const clientSlashCommand = resolveClientSlashCommand({
|
||||
text: payload.text,
|
||||
hasAttachments: outgoingAttachments.length > 0,
|
||||
});
|
||||
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blurOnSubmit) {
|
||||
messageInputRef.current?.blur();
|
||||
}
|
||||
void sendMessageWithContent(
|
||||
payload.text,
|
||||
buildOutgoingAttachments(attachments),
|
||||
payload.forceSend,
|
||||
);
|
||||
void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend);
|
||||
},
|
||||
[attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent],
|
||||
[
|
||||
attachments,
|
||||
blurOnSubmit,
|
||||
buildOutgoingAttachments,
|
||||
runClientSlashCommand,
|
||||
sendMessageWithContent,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePickImage = useCallback(async () => {
|
||||
@@ -1280,18 +1334,25 @@ export function Composer({
|
||||
|
||||
const handleQueue = useCallback(
|
||||
(payload: MessagePayload) => {
|
||||
queueMessage(payload.text, buildOutgoingAttachments(attachments));
|
||||
const outgoingAttachments = buildOutgoingAttachments(attachments);
|
||||
const clientSlashCommand = resolveClientSlashCommand({
|
||||
text: payload.text,
|
||||
hasAttachments: outgoingAttachments.length > 0,
|
||||
});
|
||||
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
||||
return;
|
||||
}
|
||||
queueMessage(payload.text, outgoingAttachments);
|
||||
},
|
||||
[attachments, buildOutgoingAttachments, queueMessage],
|
||||
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand],
|
||||
);
|
||||
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: { key: string; preventDefault: () => void }) => {
|
||||
return autocompleteOnKeyPressRef.current(event);
|
||||
},
|
||||
(event: { key: string; preventDefault: () => void }) =>
|
||||
autocompleteOnKeyPressRef.current(event),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1343,6 +1404,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isProcessing={isProcessing}
|
||||
isCompact={isMobile}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1360,6 +1422,7 @@ export function Composer({
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isProcessing,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
|
||||
@@ -13,6 +13,9 @@ interface DndContextProps {
|
||||
}
|
||||
|
||||
let latestDndContextProps: DndContextProps | null = null;
|
||||
const dndKitMocks = vi.hoisted(() => ({
|
||||
useSensor: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
|
||||
@@ -22,7 +25,7 @@ vi.mock("@dnd-kit/core", () => ({
|
||||
closestCenter: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensor: dndKitMocks.useSensor,
|
||||
useSensors: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
@@ -104,7 +107,7 @@ function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<DraggableList
|
||||
@@ -113,6 +116,7 @@ function renderList(): void {
|
||||
onDragEnd={vi.fn()}
|
||||
renderItem={renderItem}
|
||||
scrollEnabled={false}
|
||||
useDragHandle={useDragHandle}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -125,6 +129,28 @@ function getItemActiveState(item: string): string | null {
|
||||
}
|
||||
|
||||
describe("DraggableList web", () => {
|
||||
it("uses distance activation for default draggable rows", () => {
|
||||
renderList();
|
||||
|
||||
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a held pointer before activating handle-based drags", () => {
|
||||
renderList({ useDragHandle: true });
|
||||
|
||||
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
activationConstraint: { delay: 250, tolerance: 8 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears active drag state when a drag is cancelled", () => {
|
||||
renderList();
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
||||
});
|
||||
|
||||
const DND_MODIFIERS = [restrictToVerticalAxis];
|
||||
const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 };
|
||||
const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 };
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
id: string;
|
||||
@@ -143,12 +145,13 @@ export function DraggableList<T>({
|
||||
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||
enabled: showCustomScrollbar,
|
||||
});
|
||||
const pointerActivationConstraint = useDragHandle
|
||||
? HANDLE_POINTER_ACTIVATION_CONSTRAINT
|
||||
: DEFAULT_POINTER_ACTIVATION_CONSTRAINT;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 6,
|
||||
},
|
||||
activationConstraint: pointerActivationConstraint,
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@server/client/daemon-client";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
@@ -15,13 +15,8 @@ import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
highlightCode,
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
type HighlightToken,
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -31,13 +26,13 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
lineNumber: number;
|
||||
gutterWidth: number;
|
||||
colorMap: Record<HighlightStyle, string>;
|
||||
baseColor: string;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
@@ -45,7 +40,7 @@ interface FilePreviewBodyProps {
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
filePath: string;
|
||||
location: WorkspaceFileLocation;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
@@ -57,6 +52,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
interface FileLineSelection {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -99,36 +99,46 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
function clampLineSelection(input: {
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
lineCount: number;
|
||||
}): FileLineSelection | null {
|
||||
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
||||
const rawLineEnd =
|
||||
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
||||
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
colorMap,
|
||||
baseColor,
|
||||
highlighted,
|
||||
}: CodeLineProps) {
|
||||
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
||||
const gutterTextStyle = useMemo(
|
||||
() => [codeLineStyles.gutterText, { color: baseColor }],
|
||||
[baseColor],
|
||||
const lineStyle = useMemo(
|
||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
||||
[highlighted],
|
||||
);
|
||||
const keyedTokens = useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
);
|
||||
return (
|
||||
<View style={codeLineStyles.line}>
|
||||
<View style={lineStyle}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||
{String(lineNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable style={codeLineStyles.lineText}>
|
||||
{keyedTokens.map(({ key, token }) => (
|
||||
<CodeLineToken
|
||||
key={key}
|
||||
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
|
||||
text={token.text}
|
||||
/>
|
||||
<CodeLineToken key={key} token={token} />
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -136,25 +146,27 @@ const CodeLine = React.memo(function CodeLine({
|
||||
});
|
||||
|
||||
interface CodeLineTokenProps {
|
||||
color: string;
|
||||
text: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
function CodeLineToken({ color, text }: CodeLineTokenProps) {
|
||||
const style = useMemo(() => ({ color }), [color]);
|
||||
return <Text style={style}>{text}</Text>;
|
||||
function CodeLineToken({ token }: CodeLineTokenProps) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
line: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
highlightedLine: {
|
||||
backgroundColor: theme.colors.accentBorder,
|
||||
},
|
||||
gutter: {
|
||||
alignItems: "flex-end",
|
||||
paddingRight: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
gutterText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
@@ -174,16 +186,15 @@ function FilePreviewBody({
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
filePath,
|
||||
location,
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
const filePath = location.path;
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
@@ -203,12 +214,36 @@ function FilePreviewBody({
|
||||
if (!highlightedLines) return 0;
|
||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
|
||||
}, [highlightedLines, theme.fontSize.sm]);
|
||||
const lineHeight = theme.fontSize.sm * 1.45;
|
||||
const lineSelection = useMemo(() => {
|
||||
if (!highlightedLines) {
|
||||
return null;
|
||||
}
|
||||
return clampLineSelection({
|
||||
lineStart: location.lineStart,
|
||||
lineEnd: location.lineEnd,
|
||||
lineCount: highlightedLines.length,
|
||||
});
|
||||
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
||||
|
||||
const imageSource = useMemo(
|
||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||
[imagePreviewUri],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lineSelection) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
previewScrollRef.current?.scrollTo({
|
||||
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
|
||||
animated: false,
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [lineHeight, lineSelection]);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
@@ -263,8 +298,11 @@ function FilePreviewBody({
|
||||
tokens={tokens}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
colorMap={colorMap}
|
||||
baseColor={baseColor}
|
||||
highlighted={
|
||||
Boolean(lineSelection) &&
|
||||
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
||||
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -344,18 +382,18 @@ function FilePreviewBody({
|
||||
export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
filePath,
|
||||
location,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
filePath: string;
|
||||
location: WorkspaceFileLocation;
|
||||
}) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
||||
@@ -398,7 +436,7 @@ export function FilePane({
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
filePath={filePath}
|
||||
location={location}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
</View>
|
||||
|
||||
269
packages/app/src/components/highlighted-code-block.tsx
Normal file
269
packages/app/src/components/highlighted-code-block.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
code: string;
|
||||
language: string | null | undefined;
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
}
|
||||
|
||||
// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the
|
||||
// extension-based parser table in @getpaseo/highlight. Aliases here only
|
||||
// cover names that don't already match an extension key in parsers.ts.
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
typescript: "ts",
|
||||
javascript: "js",
|
||||
python: "py",
|
||||
rust: "rs",
|
||||
golang: "go",
|
||||
"c++": "cpp",
|
||||
objc: "m",
|
||||
"objective-c": "m",
|
||||
markdown: "md",
|
||||
elixir: "ex",
|
||||
};
|
||||
|
||||
function fenceLanguageToExtension(info: string | null | undefined): string | null {
|
||||
if (!info) return null;
|
||||
const first = info.trim().split(/\s+/)[0]?.toLowerCase();
|
||||
if (!first) return null;
|
||||
const normalized = first.replace(/^\./, "");
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
// Cross-instance cache for tokenized code blocks. Tokenization is
|
||||
// theme-independent (colors are applied at render time), so the key is just
|
||||
// (language, code). Bounded by entry count — 200 is generous for a chat
|
||||
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
|
||||
class LRUCache<K, V> {
|
||||
private readonly map = new Map<K, V>();
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
else if (this.map.size >= this.max) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
|
||||
|
||||
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
code,
|
||||
language,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
}: HighlightedCodeBlockProps) {
|
||||
// Box styles (bg / padding / border / radius / margin) go on the wrapper View
|
||||
// so the absolute copy button positions relative to the visible code area,
|
||||
// not to a parent that includes the Text's own marginVertical.
|
||||
const { containerStyle, innerTextStyle } = useMemo(
|
||||
() => splitFenceStyle(inheritedStyles, textStyle),
|
||||
[inheritedStyles, textStyle],
|
||||
);
|
||||
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||
const ext = fenceLanguageToExtension(language);
|
||||
if (!ext) return null;
|
||||
const cacheKey = `${ext}:${code}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let tokenizedLines: HighlightToken[][];
|
||||
try {
|
||||
tokenizedLines = highlightCode(code, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = tokenizedLines.map(toKeyedLine);
|
||||
tokenizationCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [code, language]);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const controlsVisible = isHovered || isNative || isCompact;
|
||||
const getCode = useCallback(() => code, [code]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{keyedLines ? (
|
||||
<Text style={innerTextStyle}>
|
||||
{keyedLines.map((line, lineIndex) => (
|
||||
<React.Fragment key={line.key}>
|
||||
{lineIndex > 0 ? "\n" : null}
|
||||
{line.tokens.map(({ key, token }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={innerTextStyle}>{code}</Text>
|
||||
)}
|
||||
<CopyButton getCode={getCode} visible={controlsVisible} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface KeyedToken {
|
||||
key: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
interface KeyedLine {
|
||||
key: string;
|
||||
tokens: KeyedToken[];
|
||||
}
|
||||
|
||||
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||
return {
|
||||
key: `line-${lineIndex}`,
|
||||
tokens: tokens.map((token, tokenIndex) => ({
|
||||
key: `${lineIndex}-${tokenIndex}`,
|
||||
token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenSpanProps {
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
||||
if (!token.style) return token.text;
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
});
|
||||
|
||||
interface SplitStyles {
|
||||
containerStyle: StyleProp<ViewStyle>;
|
||||
innerTextStyle: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const CONTAINER_BASE: ViewStyle = { position: "relative" };
|
||||
const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {};
|
||||
|
||||
function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles {
|
||||
const { fontFamily, fontSize, color, ...box } = textStyle;
|
||||
const textOnly: TextStyle = { ...WEB_SELECTABLE };
|
||||
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
|
||||
if (fontSize !== undefined) textOnly.fontSize = fontSize;
|
||||
if (color !== undefined) textOnly.color = color;
|
||||
return {
|
||||
containerStyle: [box as ViewStyle, CONTAINER_BASE],
|
||||
innerTextStyle: [inheritedStyles, textOnly],
|
||||
};
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
getCode: () => string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const COPIED_RESET_MS = 1500;
|
||||
|
||||
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetRef.current) clearTimeout(resetRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(async () => {
|
||||
const content = getCode();
|
||||
if (!content) return;
|
||||
await Clipboard.setStringAsync(content);
|
||||
setCopied(true);
|
||||
if (resetRef.current) clearTimeout(resetRef.current);
|
||||
resetRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
resetRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
}, [getCode]);
|
||||
|
||||
const visibilityStyle = visible
|
||||
? copyButtonStyles.containerVisible
|
||||
: copyButtonStyles.containerHidden;
|
||||
const wrapperStyle = useMemo(
|
||||
() => [copyButtonStyles.container, visibilityStyle],
|
||||
[visibilityStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={wrapperStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={copied ? "Copied" : "Copy code"}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const iconColor = hovered
|
||||
? copyButtonStyles.iconHoveredColor.color
|
||||
: copyButtonStyles.iconColor.color;
|
||||
return copied ? (
|
||||
<Check size={14} color={iconColor} />
|
||||
) : (
|
||||
<Copy size={14} color={iconColor} />
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const copyButtonStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
containerVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
containerHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
iconHoveredColor: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -2,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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
|
||||
import { FolderPlus, MessagesSquare, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -472,6 +472,7 @@ function SidebarFooter({
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
desktopMinWidth={280}
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
@@ -679,6 +680,25 @@ function MobileSidebar({
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeToAgent}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close sidebar"
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -888,6 +908,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
mobileCloseButton: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
right: theme.spacing[4],
|
||||
zIndex: 2,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
desktopSidebarBorder: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
@@ -53,6 +54,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||
import { computeCanStartDictation } from "./message-input-state";
|
||||
|
||||
@@ -143,7 +145,8 @@ export interface MessageInputRef {
|
||||
|
||||
const MIN_INPUT_HEIGHT_MOBILE = 30;
|
||||
const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const DEFAULT_MAX_INPUT_HEIGHT = 160;
|
||||
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
|
||||
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
@@ -365,6 +368,7 @@ function resolveSendTooltipLabel(input: {
|
||||
|
||||
interface DesktopKeyPressContext {
|
||||
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
|
||||
submitOnEnter: boolean;
|
||||
isAgentRunning: boolean;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
isSubmitDisabled: boolean;
|
||||
@@ -391,6 +395,7 @@ function handleDesktopKeyPressImpl(
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
|
||||
@@ -980,17 +985,22 @@ function computeIsDictationStartEnabled(
|
||||
return (isReadyForDictation ?? isConnected) && !disabled;
|
||||
}
|
||||
|
||||
function computeTextInputHeightStyle(inputHeight: number) {
|
||||
function resolveMaxInputHeight(windowHeight: number): number {
|
||||
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
|
||||
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
|
||||
}
|
||||
|
||||
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
|
||||
if (isWeb) {
|
||||
return {
|
||||
height: inputHeight,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
};
|
||||
}
|
||||
return {
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1187,6 +1197,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
} = resolveMessageInputProps(props);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const maxInputHeight = resolveMaxInputHeight(windowHeight);
|
||||
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
@@ -1451,7 +1464,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const handleSendMessage = useCallback(
|
||||
() =>
|
||||
sendMessageImpl({
|
||||
value,
|
||||
value: valueRef.current,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
@@ -1462,7 +1475,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}),
|
||||
[
|
||||
allowEmptySubmit,
|
||||
value,
|
||||
attachments,
|
||||
cwd,
|
||||
onSubmit,
|
||||
@@ -1475,14 +1487,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const handleQueueMessage = useCallback(
|
||||
() =>
|
||||
queueMessageImpl({
|
||||
value,
|
||||
value: valueRef.current,
|
||||
attachments,
|
||||
cwd,
|
||||
onQueue,
|
||||
onChangeText,
|
||||
onMinimizeHeight: minimizeInputHeight,
|
||||
}),
|
||||
[value, attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
|
||||
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
|
||||
);
|
||||
|
||||
const handleDefaultSendAction = useCallback(() => {
|
||||
@@ -1519,7 +1531,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}, [getWebTextArea]);
|
||||
|
||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
|
||||
enabled: isWeb,
|
||||
});
|
||||
|
||||
usePasteImagesEffect({
|
||||
@@ -1533,20 +1545,24 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const setBoundedInputHeight = useCallback(
|
||||
(nextHeight: number) => {
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight));
|
||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
||||
inputHeightRef.current = bounded;
|
||||
setInputHeight(bounded);
|
||||
onHeightChange?.(bounded);
|
||||
},
|
||||
[onHeightChange],
|
||||
[maxInputHeight, onHeightChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBoundedInputHeight(inputHeightRef.current);
|
||||
}, [setBoundedInputHeight]);
|
||||
|
||||
useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef: webTextareaRef,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
onHeight: setBoundedInputHeight,
|
||||
});
|
||||
|
||||
@@ -1567,12 +1583,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[onSelectionChangeCallback],
|
||||
);
|
||||
|
||||
const shouldHandleDesktopSubmit = isWeb;
|
||||
const shouldHandleWebKeyPress = isWeb;
|
||||
const shouldSubmitOnEnter = isWeb && !isCompact;
|
||||
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
if (!shouldHandleDesktopSubmit) return;
|
||||
if (!shouldHandleWebKeyPress) return;
|
||||
handleDesktopKeyPressImpl(event, {
|
||||
onKeyPressCallback,
|
||||
submitOnEnter: shouldSubmitOnEnter,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
@@ -1623,6 +1641,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
valueRef.current = nextValue;
|
||||
onChangeText(nextValue);
|
||||
},
|
||||
[onChangeText],
|
||||
@@ -1668,8 +1687,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[inputWrapperStyle, inputAnimatedStyle],
|
||||
);
|
||||
const textInputStyle = useMemo(
|
||||
() => [styles.textInput, computeTextInputHeightStyle(inputHeight)],
|
||||
[inputHeight],
|
||||
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
|
||||
[inputHeight, maxInputHeight],
|
||||
);
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled],
|
||||
@@ -1721,10 +1740,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onBlur={handleInputBlur}
|
||||
style={textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
|
||||
@@ -68,11 +68,10 @@ import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { type InlinePathTarget, parseInlinePathToken } from "@/utils/inline-path";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
||||
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import {
|
||||
@@ -91,12 +90,21 @@ import {
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import {
|
||||
AssistantInlineCodePathLink,
|
||||
classifyAssistantFileLink,
|
||||
type AssistantFileLinkSource,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
type InlinePathTarget,
|
||||
useAssistantFileLinkResolver,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
@@ -339,7 +347,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
bubble: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius.sm,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
@@ -709,7 +717,7 @@ export const LiveElapsed = memo(function LiveElapsed({
|
||||
interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
@@ -1001,70 +1009,6 @@ function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown):
|
||||
return "Unable to load image preview.";
|
||||
}
|
||||
|
||||
function MarkdownLink({
|
||||
source,
|
||||
style,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
children,
|
||||
}: {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
onPress: (source: AssistantFileLinkSource) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const href = source.href;
|
||||
const handlePress = useCallback(() => onPress(source), [onPress, source]);
|
||||
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
||||
const handleHoverIn = useCallback(() => {
|
||||
setHovered(true);
|
||||
handlePrefetch();
|
||||
}, [handlePrefetch]);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
if (isNative) {
|
||||
return (
|
||||
<Text accessibilityRole="link" onPress={handlePress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClickCapture={preventAnchorNavigation}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={MARKDOWN_LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onFocus={handlePrefetch}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKDOWN_LINK_ANCHOR_STYLE: React.CSSProperties = {
|
||||
display: "contents",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
function preventAnchorNavigation(event: React.MouseEvent<HTMLAnchorElement>): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string,
|
||||
@@ -1556,76 +1500,6 @@ function MarkdownInheritedText({
|
||||
return <Text style={style}>{children}</Text>;
|
||||
}
|
||||
|
||||
interface MarkdownInheritedCodeLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownInheritedCodeLink({
|
||||
source,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
children,
|
||||
}: MarkdownInheritedCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, codeInlineStyle, linkStyle],
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownLink source={source} style={style} onPress={onPress} onPrefetch={onPrefetch}>
|
||||
{children}
|
||||
</MarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInlinePathCodeLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
}
|
||||
|
||||
function MarkdownInlinePathCodeLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
}: MarkdownInlinePathCodeLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
>
|
||||
{content}
|
||||
</MarkdownInheritedCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
@@ -1718,8 +1592,8 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
});
|
||||
|
||||
const handleLinkPress = useCallback(
|
||||
(source: AssistantFileLinkSource) => {
|
||||
fileLinkResolver.open({ source });
|
||||
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||
fileLinkResolver.open({ source, disposition });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
@@ -1731,7 +1605,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
const handleMarkdownLinkPress = useCallback(
|
||||
(url: string) => {
|
||||
fileLinkResolver.open({ source: { href: url } });
|
||||
fileLinkResolver.open({ source: { href: url }, disposition: "main" });
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
@@ -1778,13 +1652,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
@@ -1793,13 +1667,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
@@ -1810,12 +1684,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
|
||||
const shouldResolveInlinePath =
|
||||
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content);
|
||||
onInlinePathPress &&
|
||||
!isLinkedInlineCode &&
|
||||
inlineCodeFileLink &&
|
||||
inlineCodeFileLink.kind !== "external";
|
||||
|
||||
if (shouldResolveInlinePath) {
|
||||
return (
|
||||
<MarkdownInlinePathCodeLink
|
||||
<AssistantInlineCodePathLink
|
||||
key={node.key}
|
||||
content={content}
|
||||
inheritedStyles={inheritedStyles}
|
||||
@@ -1823,6 +1701,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1834,7 +1713,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
content,
|
||||
});
|
||||
return (
|
||||
<MarkdownInheritedCodeLink
|
||||
<AssistantMarkdownCodeLink
|
||||
key={node.key}
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
@@ -1842,9 +1721,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{content}
|
||||
</MarkdownInheritedCodeLink>
|
||||
</AssistantMarkdownCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1916,12 +1796,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownLink
|
||||
<AssistantMarkdownLink
|
||||
key={node.key}
|
||||
source={getMarkdownLinkSource(node)}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
@@ -1930,7 +1811,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
style: [childProps.style, { color: styles.link.color }],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||||
})}
|
||||
</MarkdownLink>
|
||||
</AssistantMarkdownLink>
|
||||
),
|
||||
image: (
|
||||
node: ASTNode,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -594,7 +594,7 @@ function ProjectKebabMenu({
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${projectKey}`}
|
||||
>
|
||||
@@ -725,7 +725,7 @@ function WorkspaceKebabMenu({
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={workspaceKebabStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
@@ -895,7 +895,7 @@ function NewWorktreeButton({
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
@@ -1187,6 +1187,12 @@ function ProjectHeaderRow({
|
||||
drag,
|
||||
menuController,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
@@ -1250,7 +1256,7 @@ function ProjectHeaderRow({
|
||||
if (menuController) {
|
||||
return (
|
||||
<View
|
||||
{...dragHandleProps?.attributes}
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
@@ -1258,6 +1264,7 @@ function ProjectHeaderRow({
|
||||
>
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
@@ -1273,13 +1280,14 @@ function ProjectHeaderRow({
|
||||
|
||||
return (
|
||||
<View
|
||||
{...dragHandleProps?.attributes}
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
@@ -1328,6 +1336,12 @@ function WorkspaceRowInner({
|
||||
drag,
|
||||
menuController,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
@@ -1369,7 +1383,7 @@ function WorkspaceRowInner({
|
||||
return (
|
||||
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||
<View
|
||||
{...dragHandleProps?.attributes}
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
|
||||
@@ -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']",
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface SplitContainerProps {
|
||||
@@ -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,
|
||||
|
||||
@@ -116,6 +116,7 @@ interface TerminalEmulatorProps {
|
||||
streamKey: string;
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
@@ -188,6 +189,7 @@ export default function TerminalEmulator({
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
@@ -206,6 +208,8 @@ export default function TerminalEmulator({
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||
const scrollbackLinesRef = useRef(scrollbackLines);
|
||||
scrollbackLinesRef.current = scrollbackLines;
|
||||
const viewportRef = useRef<HTMLElement | null>(null);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
@@ -289,6 +293,10 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.setTheme({ theme: nextTheme });
|
||||
}, [themeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
||||
}, [scrollbackLines]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureTerminalScrollbarStyle();
|
||||
}, []);
|
||||
@@ -429,6 +437,7 @@ export default function TerminalEmulator({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
scrollback: scrollbackLinesRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -157,6 +158,7 @@ export function TerminalPane({
|
||||
}: TerminalPaneProps) {
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const { settings } = useAppSettings();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
@@ -681,6 +683,7 @@ export function TerminalPane({
|
||||
streamKey={terminalStreamKey}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
scrollbackLines={settings.terminalScrollbackLines}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
type FlatList,
|
||||
type LayoutChangeEvent,
|
||||
@@ -21,8 +21,25 @@ function ensureHideScrollbarStyle(): void {
|
||||
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = HIDE_SCROLLBAR_STYLE_ID;
|
||||
style.textContent =
|
||||
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
|
||||
style.textContent = `
|
||||
[data-hide-scrollbar] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
[data-hide-scrollbar]::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
[data-hide-scrollbar]::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@@ -54,18 +71,25 @@ export function useWebElementScrollbar(
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
type ScrollbarStyle = CSSStyleDeclaration & {
|
||||
scrollbarWidth: string;
|
||||
msOverflowStyle: string;
|
||||
scrollbarGutter: string;
|
||||
};
|
||||
const style = element.style as ScrollbarStyle;
|
||||
const previousScrollbarWidth = style.scrollbarWidth;
|
||||
const previousMsOverflowStyle = style.msOverflowStyle;
|
||||
const previousScrollbarGutter = style.scrollbarGutter;
|
||||
|
||||
element.setAttribute("data-hide-scrollbar", "");
|
||||
(
|
||||
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
|
||||
).scrollbarWidth = "none";
|
||||
(
|
||||
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
|
||||
).msOverflowStyle = "none";
|
||||
style.scrollbarWidth = "none";
|
||||
style.msOverflowStyle = "none";
|
||||
style.scrollbarGutter = "auto";
|
||||
ensureHideScrollbarStyle();
|
||||
|
||||
function update() {
|
||||
@@ -94,12 +118,9 @@ export function useWebElementScrollbar(
|
||||
element.removeEventListener("scroll", update);
|
||||
resizeObserver.disconnect();
|
||||
element.removeAttribute("data-hide-scrollbar");
|
||||
(
|
||||
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
|
||||
).scrollbarWidth = "";
|
||||
(
|
||||
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
|
||||
).msOverflowStyle = "";
|
||||
style.scrollbarWidth = previousScrollbarWidth;
|
||||
style.msOverflowStyle = previousMsOverflowStyle;
|
||||
style.scrollbarGutter = previousScrollbarGutter;
|
||||
};
|
||||
}, [contentRef, elementRef, enabled]);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import { Dimensions, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
|
||||
@@ -23,6 +23,8 @@ import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
@@ -84,8 +86,9 @@ export function WorkspaceHoverCard({
|
||||
isDragging,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
|
||||
// Desktop-only: skip on non-web platforms
|
||||
if (Platform.OS !== "web") {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
if (!isWeb || isCompact) {
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -54,11 +54,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "@/components/diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
type HighlightStyle as HighlightStyleKey,
|
||||
} from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
||||
@@ -136,8 +132,6 @@ function expandAllButtonStyle({
|
||||
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
|
||||
}
|
||||
|
||||
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
wrapLines?: boolean;
|
||||
@@ -157,31 +151,14 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
|
||||
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||
}
|
||||
|
||||
function HighlightedToken({
|
||||
text,
|
||||
color,
|
||||
lineHeight,
|
||||
}: {
|
||||
text: string;
|
||||
color: string;
|
||||
lineHeight: number;
|
||||
}) {
|
||||
const tokenStyle = useMemo(() => ({ color, lineHeight }), [color, lineHeight]);
|
||||
return <Text style={tokenStyle}>{text}</Text>;
|
||||
function HighlightedToken({ token }: { token: HighlightToken }) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const lineHeight = theme.lineHeight.diff;
|
||||
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
if (!style) return baseColor;
|
||||
const colors = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
return colors[style as HighlightStyleKey] ?? baseColor;
|
||||
};
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
|
||||
[lineHeight, wrapLines],
|
||||
@@ -195,12 +172,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
return (
|
||||
<Text style={containerStyle}>
|
||||
{keyedTokens.map(({ key, token }) => (
|
||||
<HighlightedToken
|
||||
key={key}
|
||||
text={token.text}
|
||||
color={getTokenColor(token.style)}
|
||||
lineHeight={lineHeight}
|
||||
/>
|
||||
<HighlightedToken key={key} token={token} />
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
@@ -870,37 +842,46 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
return (
|
||||
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={fileHeaderPressableStyle}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={toggleExpanded}
|
||||
>
|
||||
<View style={styles.fileHeaderLeft}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{file.path.split("/").pop()}
|
||||
</Text>
|
||||
<Text style={styles.fileDir} numberOfLines={1}>
|
||||
{file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""}
|
||||
</Text>
|
||||
{file.isNew && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>New</Text>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={fileHeaderPressableStyle}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={toggleExpanded}
|
||||
>
|
||||
<View style={styles.fileHeaderLeft}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{file.path.split("/").pop()}
|
||||
</Text>
|
||||
<Text style={styles.fileDir} numberOfLines={1}>
|
||||
{file.path.includes("/")
|
||||
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
||||
: ""}
|
||||
</Text>
|
||||
{file.isNew && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>New</Text>
|
||||
</View>
|
||||
)}
|
||||
{file.isDeleted && (
|
||||
<View style={styles.deletedBadge}>
|
||||
<Text style={styles.deletedBadgeText}>Deleted</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{file.isDeleted && (
|
||||
<View style={styles.deletedBadge}>
|
||||
<Text style={styles.deletedBadgeText}>Deleted</Text>
|
||||
<View style={styles.fileHeaderRight}>
|
||||
<DiffStat additions={file.additions} deletions={file.deletions} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.fileHeaderRight}>
|
||||
<DiffStat additions={file.additions} deletions={file.deletions} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
|
||||
<Text style={styles.tooltipText}>{file.path}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
applyFileMentionReplacement,
|
||||
findActiveFileMention,
|
||||
@@ -20,10 +25,13 @@ interface UseAgentAutocompleteInput {
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
onAutocompleteApplied?: () => void;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||
canExecuteClientSlashCommand?: boolean;
|
||||
}
|
||||
|
||||
type AgentAutocompleteOption =
|
||||
| (AutocompleteOption & { type: "command" })
|
||||
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
|
||||
| (AutocompleteOption & { type: "provider_command" })
|
||||
| (AutocompleteOption & {
|
||||
type: "workspace_entry";
|
||||
entryPath: string;
|
||||
@@ -47,6 +55,10 @@ interface DirectorySuggestionEntry {
|
||||
kind: "file" | "directory";
|
||||
}
|
||||
|
||||
type AvailableCommand =
|
||||
| { source: "client"; command: ClientSlashCommand }
|
||||
| { source: "provider"; command: AgentSlashCommand };
|
||||
|
||||
function normalizeDraftCommandConfig(
|
||||
draftConfig?: DraftCommandConfig,
|
||||
): DraftCommandConfig | undefined {
|
||||
@@ -96,6 +108,28 @@ function mapDirectorySuggestionsToEntries(payload: {
|
||||
}));
|
||||
}
|
||||
|
||||
function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
|
||||
const command = entry.command;
|
||||
const base = {
|
||||
id: command.name,
|
||||
label: `/${command.name}`,
|
||||
detail: command.argumentHint || undefined,
|
||||
description: command.description,
|
||||
kind: "command" as const,
|
||||
};
|
||||
if (entry.source === "client") {
|
||||
return {
|
||||
...base,
|
||||
type: "client_command",
|
||||
command: entry.command,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: "provider_command",
|
||||
};
|
||||
}
|
||||
|
||||
type AutocompleteMode = "command" | "file" | null;
|
||||
|
||||
function resolveAutocompleteMode(args: {
|
||||
@@ -170,6 +204,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
agentId,
|
||||
draftConfig,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
canExecuteClientSlashCommand,
|
||||
} = input;
|
||||
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
@@ -277,16 +313,22 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
|
||||
if (mode === "command") {
|
||||
const filterLower = commandFilterQuery.toLowerCase();
|
||||
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
|
||||
const providerCommands = commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const availableCommands: AvailableCommand[] = isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands,
|
||||
];
|
||||
const matches = availableCommands.filter((entry) =>
|
||||
entry.command.name.toLowerCase().includes(filterLower),
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map((cmd) => ({
|
||||
type: "command" as const,
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
detail: cmd.argumentHint || undefined,
|
||||
description: cmd.description,
|
||||
kind: "command",
|
||||
}));
|
||||
return orderedMatches.map(mapCommandToOption);
|
||||
}
|
||||
|
||||
if (mode === "file" && activeFileMention) {
|
||||
@@ -302,12 +344,30 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]);
|
||||
}, [
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
const selected = option as AgentAutocompleteOption;
|
||||
if (selected.type === "command") {
|
||||
if (
|
||||
selected.type === "client_command" &&
|
||||
selected.command.execution === "immediate" &&
|
||||
canExecuteClientSlashCommand &&
|
||||
onClientSlashCommand
|
||||
) {
|
||||
onClientSlashCommand(selected.command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.type === "client_command" || selected.type === "provider_command") {
|
||||
setUserInput(`/${selected.id} `);
|
||||
onAutocompleteApplied?.();
|
||||
return;
|
||||
@@ -321,7 +381,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
setUserInput(nextInput);
|
||||
onAutocompleteApplied?.();
|
||||
},
|
||||
[onAutocompleteApplied, setUserInput, userInput],
|
||||
[
|
||||
canExecuteClientSlashCommand,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
setUserInput,
|
||||
userInput,
|
||||
],
|
||||
);
|
||||
|
||||
const { selectedIndex, onKeyPress } = useAutocomplete({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||
|
||||
interface AgentSlashCommand {
|
||||
export interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ type AttachmentUpdater =
|
||||
interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
isVisible?: boolean;
|
||||
onlineServerIds?: string[];
|
||||
lockedWorkingDir?: string;
|
||||
@@ -229,6 +230,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
modeId: formState.selectedMode,
|
||||
modelId: effectiveModelId,
|
||||
thinkingOptionId: effectiveThinkingOptionId,
|
||||
initialFeatureValues: composerOptions?.initialFeatureValues,
|
||||
});
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
|
||||
40
packages/app/src/hooks/use-compact-web-viewport-zoom-lock.ts
Normal file
40
packages/app/src/hooks/use-compact-web-viewport-zoom-lock.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const COMPACT_WEB_VIEWPORT_CONTENT =
|
||||
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover";
|
||||
const DEFAULT_WEB_VIEWPORT_CONTENT = "width=device-width, initial-scale=1, viewport-fit=cover";
|
||||
|
||||
export function useCompactWebViewportZoomLock(isCompactLayout: boolean) {
|
||||
useEffect(() => {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportMeta =
|
||||
document.querySelector<HTMLMetaElement>('meta[name="viewport"]') ??
|
||||
document.createElement("meta");
|
||||
const hadViewportMeta = viewportMeta.parentElement !== null;
|
||||
const previousContent = viewportMeta.getAttribute("content");
|
||||
|
||||
if (!hadViewportMeta) {
|
||||
viewportMeta.name = "viewport";
|
||||
document.head.appendChild(viewportMeta);
|
||||
}
|
||||
|
||||
viewportMeta.setAttribute(
|
||||
"content",
|
||||
isCompactLayout ? COMPACT_WEB_VIEWPORT_CONTENT : DEFAULT_WEB_VIEWPORT_CONTENT,
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (!hadViewportMeta) {
|
||||
viewportMeta.remove();
|
||||
return;
|
||||
}
|
||||
if (previousContent !== null) {
|
||||
viewportMeta.setAttribute("content", previousContent);
|
||||
}
|
||||
};
|
||||
}, [isCompactLayout]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -21,14 +21,19 @@ export function useDraftAgentFeatures(input: {
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
thinkingOptionId: string | null | undefined;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
}) {
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
|
||||
input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
|
||||
() => initialFeatureValues ?? {},
|
||||
);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
|
||||
const persistedFeatureValues = useMemo(
|
||||
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||
[preferences.providerPreferences, provider],
|
||||
@@ -88,15 +93,25 @@ export function useDraftAgentFeatures(input: {
|
||||
}, [availableFeatures, featureValues]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalFeatureValues({});
|
||||
}, [provider]);
|
||||
const previousProvider = previousProviderRef.current;
|
||||
previousProviderRef.current = normalizedProvider;
|
||||
if (previousProvider === null) {
|
||||
return;
|
||||
}
|
||||
if (previousProvider !== normalizedProvider) {
|
||||
setLocalFeatureValues({});
|
||||
}
|
||||
}, [normalizedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableFeaturesRaw === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
|
||||
if (next !== localFeatureValues) {
|
||||
setLocalFeatureValues(next);
|
||||
}
|
||||
}, [availableFeatures, localFeatureValues]);
|
||||
}, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
|
||||
|
||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
||||
const setFeatureValue = useCallback(
|
||||
|
||||
@@ -3,12 +3,15 @@ import { Gesture } from "react-native-gesture-handler";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface UseExplorerOpenGestureParams {
|
||||
enabled: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||
|
||||
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
||||
const {
|
||||
translateX,
|
||||
@@ -56,6 +59,12 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
|
||||
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail quickly on rightward or clearly vertical intent.
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
@@ -87,6 +88,7 @@ describe("use-settings", () => {
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
@@ -130,6 +132,7 @@ describe("use-settings", () => {
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
@@ -169,6 +172,7 @@ describe("use-settings", () => {
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -188,8 +192,58 @@ describe("use-settings", () => {
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads configured terminal scrollback lines from app settings", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
terminalScrollbackLines: 42_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result.terminalScrollbackLines).toBe(42_000);
|
||||
});
|
||||
|
||||
it("saves terminal scrollback through app settings persistence", async () => {
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
asyncStorageMock.getItem.mockResolvedValue(JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS));
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await mod.saveAppSettings({
|
||||
queryClient,
|
||||
updates: { terminalScrollbackLines: 42_000 },
|
||||
});
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenLastCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
...mod.DEFAULT_CLIENT_SETTINGS,
|
||||
terminalScrollbackLines: 42_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes terminal scrollback lines from storage", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
terminalScrollbackLines: 1_000_000.9,
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result.terminalScrollbackLines).toBe(1_000_000);
|
||||
expect(mod.parseTerminalScrollbackLines("-10")).toBe(0);
|
||||
expect(mod.parseTerminalScrollbackLines("abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
@@ -21,11 +21,15 @@ export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
terminalScrollbackLines: number;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -37,6 +41,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -73,11 +78,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
const prev =
|
||||
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
await saveAppSettings({ queryClient, updates });
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to save settings:", err);
|
||||
throw err;
|
||||
@@ -122,7 +123,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (updates.serviceUrlBehavior !== undefined) {
|
||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||
}
|
||||
|
||||
if (updates.terminalScrollbackLines !== undefined) {
|
||||
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
|
||||
}
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
@@ -171,11 +174,18 @@ export function useSettings(): UseSettingsReturn {
|
||||
}
|
||||
|
||||
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
|
||||
await saveAppSettings({ queryClient: appQueryClient, updates });
|
||||
}
|
||||
|
||||
export async function saveAppSettings(input: {
|
||||
queryClient: QueryClient;
|
||||
updates: Partial<AppSettings>;
|
||||
}): Promise<void> {
|
||||
const current =
|
||||
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
(await loadAppSettingsFromStorage());
|
||||
const next = { ...current, ...updates };
|
||||
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
const next = { ...current, ...input.updates };
|
||||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
@@ -246,6 +256,10 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||
}
|
||||
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
||||
if (terminalScrollbackLines !== null) {
|
||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -257,6 +271,22 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||
let numericValue = NaN;
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||||
numericValue = Number(value);
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import {
|
||||
@@ -48,18 +47,22 @@ import {
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { SubagentsSection, useArchiveSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
|
||||
interface ChatAgentStateShape {
|
||||
serverId: string | null;
|
||||
@@ -285,18 +288,12 @@ function AgentPanel() {
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
|
||||
function openWorkspaceFile(input: { filePath: string }) {
|
||||
openFileInWorkspace(input.filePath);
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile);
|
||||
|
||||
return (
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
agentId={target.agentId}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -362,7 +359,7 @@ function AgentPanelContent({
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const resolvedAgentId = agentId.trim() || undefined;
|
||||
const resolvedServerId = serverId.trim() || undefined;
|
||||
@@ -424,7 +421,7 @@ function AgentPanelBody({
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
@@ -589,7 +586,7 @@ function ChatAgentContent({
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const panelToast = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
@@ -1038,7 +1035,7 @@ function AgentStreamSection({
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
toast: ReturnType<typeof useToastHost>["api"];
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
@@ -1257,7 +1254,11 @@ function ActiveAgentComposer({
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId } = paneContext;
|
||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const subagentRows = useSubagentsForParent({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
@@ -1305,6 +1306,44 @@ function ActiveAgentComposer({
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const handleClientSlashCommand = useCallback(
|
||||
async (command: ClientSlashCommand) => {
|
||||
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
|
||||
if (!agent) {
|
||||
throw new Error("Agent not found");
|
||||
}
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (workspaceKey) {
|
||||
unpinWorkspaceAgent(workspaceKey, agentId);
|
||||
hideWorkspaceAgent(workspaceKey, agentId);
|
||||
}
|
||||
|
||||
if (command.kind === "replace-agent-with-draft") {
|
||||
retargetCurrentTab({
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
setup: buildDraftAgentSetup(agent),
|
||||
});
|
||||
} else if (workspaceKey) {
|
||||
closeWorkspaceTab(workspaceKey, tabId);
|
||||
}
|
||||
|
||||
await archiveAgent({ serverId, agentId });
|
||||
},
|
||||
[
|
||||
agentId,
|
||||
archiveAgent,
|
||||
closeWorkspaceTab,
|
||||
hideWorkspaceAgent,
|
||||
retargetCurrentTab,
|
||||
serverId,
|
||||
tabId,
|
||||
unpinWorkspaceAgent,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
const inputAreaStyle = useMemo(
|
||||
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
@@ -1336,6 +1375,7 @@ function ActiveAgentComposer({
|
||||
onAddImages={onAddImages}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
onMessageSent={onMessageSent}
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -30,13 +30,6 @@ function DraftPanel() {
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
|
||||
const handleOpenWorkspaceFile = useCallback(
|
||||
({ filePath }: { filePath: string }) => {
|
||||
openFileInWorkspace(filePath);
|
||||
},
|
||||
[openFileInWorkspace],
|
||||
);
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
@@ -56,8 +49,9 @@ function DraftPanel() {
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
initialSetup={target.setup}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
onCreated={handleCreated}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ function FilePanel() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
|
||||
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
|
||||
}
|
||||
|
||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
|
||||
export interface PaneContextValue {
|
||||
serverId: string;
|
||||
@@ -10,7 +11,7 @@ export interface PaneContextValue {
|
||||
openTab: (target: WorkspaceTabTarget) => void;
|
||||
closeCurrentTab: () => void;
|
||||
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
|
||||
openFileInWorkspace: (filePath: string) => void;
|
||||
openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
|
||||
openImportSheet: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
@@ -37,6 +38,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import {
|
||||
useAppSettings,
|
||||
useSettings,
|
||||
parseTerminalScrollbackLines,
|
||||
type AppSettings,
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
@@ -209,6 +211,7 @@ interface GeneralSectionProps {
|
||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||
}
|
||||
|
||||
interface ThemeMenuItemProps {
|
||||
@@ -267,10 +270,35 @@ function GeneralSection({
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
handleTerminalScrollbackLinesChange,
|
||||
}: GeneralSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconSize = theme.iconSize.md;
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
|
||||
String(settings.terminalScrollbackLines),
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackChangeText = useCallback((value: string) => {
|
||||
setTerminalScrollbackValue(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitTerminalScrollback = useCallback(() => {
|
||||
const parsed = parseTerminalScrollbackLines(terminalScrollbackValue);
|
||||
const nextValue = parsed ?? settings.terminalScrollbackLines;
|
||||
setTerminalScrollbackValue(String(nextValue));
|
||||
if (nextValue !== settings.terminalScrollbackLines) {
|
||||
handleTerminalScrollbackLinesChange(nextValue);
|
||||
}
|
||||
}, [
|
||||
handleTerminalScrollbackLinesChange,
|
||||
settings.terminalScrollbackLines,
|
||||
terminalScrollbackValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalScrollbackValue(String(settings.terminalScrollbackLines));
|
||||
}, [settings.terminalScrollbackLines]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="General">
|
||||
@@ -350,6 +378,23 @@ function GeneralSection({
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Terminal scrollback</Text>
|
||||
<Text style={settingsStyles.rowHint}>Lines kept in the built-in terminal buffer</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={terminalScrollbackValue}
|
||||
onChangeText={handleTerminalScrollbackChangeText}
|
||||
onBlur={commitTerminalScrollback}
|
||||
onSubmitEditing={commitTerminalScrollback}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.terminalScrollbackInput}
|
||||
accessibilityLabel="Terminal scrollback lines"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -850,6 +895,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackLinesChange = useCallback(
|
||||
(terminalScrollbackLines: number) => {
|
||||
void updateSettings({ terminalScrollbackLines });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -1031,6 +1083,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
handleThemeChange={handleThemeChange}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
);
|
||||
case "shortcuts":
|
||||
@@ -1233,6 +1286,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
terminalScrollbackInput: {
|
||||
width: 112,
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "right",
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
@@ -19,6 +20,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
@@ -31,8 +33,10 @@ import {
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -271,14 +275,51 @@ function buildDraftAgentSnapshot(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraftInitialValues(input: {
|
||||
workingDir: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): CreateAgentInitialValues | undefined {
|
||||
if (!input.workingDir) {
|
||||
return undefined;
|
||||
}
|
||||
if (!input.initialSetup) {
|
||||
return { workingDir: input.workingDir };
|
||||
}
|
||||
return {
|
||||
workingDir: input.workingDir,
|
||||
provider: input.initialSetup.provider,
|
||||
modeId: input.initialSetup.modeId,
|
||||
model: input.initialSetup.model,
|
||||
thinkingOptionId: input.initialSetup.thinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] {
|
||||
if (!input.isConnected) {
|
||||
return EMPTY_ONLINE_SERVER_IDS;
|
||||
}
|
||||
return [input.serverId];
|
||||
}
|
||||
|
||||
interface WorkspaceDraftAgentTabProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
draftId: string;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
isPaneFocused: boolean;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||
onOpenImportSheet?: () => void;
|
||||
}
|
||||
|
||||
@@ -287,6 +328,7 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceId,
|
||||
tabId,
|
||||
draftId,
|
||||
initialSetup = undefined,
|
||||
isPaneFocused,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
@@ -298,6 +340,16 @@ export function WorkspaceDraftAgentTab({
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
|
||||
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||
const draftSetup = initialSetup ?? null;
|
||||
const draftWorkingDirectory = resolveDraftWorkingDirectory({
|
||||
workspaceDirectory,
|
||||
initialSetup: draftSetup,
|
||||
});
|
||||
const draftInitialValues = buildDraftInitialValues({
|
||||
workingDir: draftWorkingDirectory,
|
||||
initialSetup: draftSetup,
|
||||
});
|
||||
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const draftStoreKey = useMemo(
|
||||
() =>
|
||||
@@ -312,10 +364,11 @@ export function WorkspaceDraftAgentTab({
|
||||
draftKey: draftStoreKey,
|
||||
composer: {
|
||||
initialServerId: serverId,
|
||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
||||
initialValues: draftInitialValues,
|
||||
initialFeatureValues: draftSetup?.featureValues,
|
||||
isVisible: true,
|
||||
onlineServerIds: isConnected ? [serverId] : [],
|
||||
lockedWorkingDir: workspaceDirectory ?? undefined,
|
||||
onlineServerIds,
|
||||
lockedWorkingDir: draftWorkingDirectory ?? undefined,
|
||||
},
|
||||
});
|
||||
const composerState = draftInput.composerState;
|
||||
@@ -381,7 +434,7 @@ export function WorkspaceDraftAgentTab({
|
||||
allowsEmptyAutoSubmit,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
hasClient: Boolean(client),
|
||||
}),
|
||||
onBeforeSubmit: () => {
|
||||
@@ -396,7 +449,7 @@ export function WorkspaceDraftAgentTab({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
}),
|
||||
@@ -407,7 +460,7 @@ export function WorkspaceDraftAgentTab({
|
||||
images,
|
||||
attachments,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceExecutionAuthority,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
@@ -421,7 +474,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const isReadyForPendingAutoSubmit = Boolean(
|
||||
pendingAutoSubmit &&
|
||||
draftInput.isHydrated &&
|
||||
workspaceDirectory &&
|
||||
draftWorkingDirectory &&
|
||||
client &&
|
||||
!isSubmitting &&
|
||||
!composerState.isModelLoading,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
|
||||
export interface WorkspacePaneContentModel {
|
||||
key: string;
|
||||
@@ -23,7 +24,7 @@ export interface BuildWorkspacePaneContentModelInput {
|
||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onCloseCurrentTab: () => void;
|
||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onOpenWorkspaceFile: (filePath: string) => void;
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||
onOpenImportSheet: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
resolveSideFileOpenPlacement,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
@@ -104,4 +105,104 @@ describe("workspace-pane-state", () => {
|
||||
path: "/repo/README.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves side file opens to an existing file tab", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: ["file_/repo/README.md"],
|
||||
focusedTabId: "file_/repo/README.md",
|
||||
},
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
};
|
||||
const tabs = [createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" })];
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "main",
|
||||
tabs,
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
}),
|
||||
).toEqual({ kind: "open-in-source" });
|
||||
});
|
||||
|
||||
it("resolves side file opens to an existing file tab when only the line range differs", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: ["file_/repo/README.md"],
|
||||
focusedTabId: "file_/repo/README.md",
|
||||
},
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
};
|
||||
const tabs = [createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" })];
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "main",
|
||||
tabs,
|
||||
target: { kind: "file", path: "/repo/README.md", lineStart: 12, lineEnd: 20 },
|
||||
}),
|
||||
).toEqual({ kind: "open-in-source" });
|
||||
});
|
||||
|
||||
it("resolves side file opens to an existing right pane", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{
|
||||
kind: "pane",
|
||||
pane: { id: "left", tabIds: ["agent_agent-a"], focusedTabId: "agent_agent-a" },
|
||||
},
|
||||
{
|
||||
kind: "pane",
|
||||
pane: { id: "right", tabIds: [], focusedTabId: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "left",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "left",
|
||||
tabs: [createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" })],
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
}),
|
||||
).toEqual({ kind: "focus-side-pane", paneId: "right" });
|
||||
});
|
||||
|
||||
it("resolves side file opens to a split when there is no right pane", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: { id: "main", tabIds: ["agent_agent-a"], focusedTabId: "agent_agent-a" },
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "main",
|
||||
tabs: [createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" })],
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
}),
|
||||
).toEqual({ kind: "split-side-pane", paneId: "main" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
} from "@/workspace-tabs/identity";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
|
||||
export interface WorkspaceDerivedTab {
|
||||
descriptor: WorkspaceTabDescriptor;
|
||||
@@ -23,6 +24,11 @@ export interface WorkspacePaneState {
|
||||
activeTab: WorkspaceDerivedTab | null;
|
||||
}
|
||||
|
||||
export type WorkspaceSideFileOpenPlacement =
|
||||
| { kind: "open-in-source" }
|
||||
| { kind: "focus-side-pane"; paneId: string }
|
||||
| { kind: "split-side-pane"; paneId: string };
|
||||
|
||||
interface NormalizeWorkspacePaneTabsResult {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
openTabIds: Set<string>;
|
||||
@@ -196,3 +202,31 @@ export function getWorkspacePaneDescriptors(input: {
|
||||
}): WorkspaceTabDescriptor[] {
|
||||
return deriveWorkspacePaneState(input).tabs.map((tab) => tab.descriptor);
|
||||
}
|
||||
|
||||
export function resolveSideFileOpenPlacement(input: {
|
||||
layout?: WorkspaceLayout | null;
|
||||
sourcePaneId?: string | null;
|
||||
tabs: WorkspaceTab[];
|
||||
target: WorkspaceTabTarget;
|
||||
}): WorkspaceSideFileOpenPlacement {
|
||||
const targetTabId = buildDeterministicWorkspaceTabId(input.target);
|
||||
const existingTab = input.tabs.find(
|
||||
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return { kind: "open-in-source" };
|
||||
}
|
||||
|
||||
const layout = input.layout ?? null;
|
||||
const sourcePaneId = trimNonEmpty(input.sourcePaneId);
|
||||
if (!layout || !sourcePaneId) {
|
||||
return { kind: "open-in-source" };
|
||||
}
|
||||
|
||||
const sidePaneId = findAdjacentPane(layout.root, sourcePaneId, "right");
|
||||
if (sidePaneId) {
|
||||
return { kind: "focus-side-pane", paneId: sidePaneId };
|
||||
}
|
||||
|
||||
return { kind: "split-side-pane", paneId: sourcePaneId };
|
||||
}
|
||||
|
||||
@@ -77,10 +77,7 @@ import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-s
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeClient,
|
||||
@@ -135,7 +132,10 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
resolveSideFileOpenPlacement,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
WorkspacePaneContent,
|
||||
@@ -158,12 +158,19 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type WorkspaceFileLocation,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
|
||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
@@ -1495,6 +1502,9 @@ function WorkspaceScreenContent({
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
|
||||
(state) => state.openChildTabFocused,
|
||||
);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
@@ -2019,7 +2029,11 @@ function WorkspaceScreenContent({
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "file", path: filePath });
|
||||
const location = normalizeWorkspaceFileLocation({ path: filePath });
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const tabId = openWorkspaceTabFocused(persistenceKey, createWorkspaceFileTabTarget(location));
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
@@ -2028,14 +2042,85 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
({ filePath }: { filePath: string }) => {
|
||||
const normalizedFilePath = filePath.trim();
|
||||
if (!normalizedFilePath) {
|
||||
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
|
||||
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
||||
if (!normalizedLocation) {
|
||||
return;
|
||||
}
|
||||
handleOpenFileFromExplorer(normalizedFilePath);
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
}
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const target = createWorkspaceFileTabTarget(normalizedLocation);
|
||||
const tabId = options?.parentTabId
|
||||
? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId)
|
||||
: openWorkspaceTabFocused(persistenceKey, target);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[handleOpenFileFromExplorer],
|
||||
[
|
||||
isMobile,
|
||||
navigateToTabId,
|
||||
openWorkspaceChildTabFocused,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
showMobileAgent,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFileFromChatInSidePane = useCallback(
|
||||
(input: {
|
||||
location: WorkspaceFileLocation;
|
||||
sourcePaneId?: string;
|
||||
parentTabId?: string | null;
|
||||
}) => {
|
||||
const location = normalizeWorkspaceFileLocation(input.location);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
||||
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
|
||||
const placement = resolveSideFileOpenPlacement({
|
||||
layout: workspaceLayout,
|
||||
sourcePaneId: input.sourcePaneId,
|
||||
tabs: uiTabs,
|
||||
target,
|
||||
});
|
||||
if (placement.kind === "focus-side-pane") {
|
||||
focusWorkspacePane(persistenceKey, placement.paneId);
|
||||
} else if (placement.kind === "split-side-pane") {
|
||||
splitWorkspacePaneEmpty(persistenceKey, {
|
||||
targetPaneId: placement.paneId,
|
||||
position: "right",
|
||||
});
|
||||
}
|
||||
|
||||
const tabId = input.parentTabId
|
||||
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
|
||||
: openWorkspaceTabFocused(persistenceKey, target);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[
|
||||
handleOpenFileFromChat,
|
||||
isMobile,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
openWorkspaceChildTabFocused,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
splitWorkspacePaneEmpty,
|
||||
uiTabs,
|
||||
workspaceLayout,
|
||||
],
|
||||
);
|
||||
|
||||
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||
@@ -2688,7 +2773,7 @@ function WorkspaceScreenContent({
|
||||
if (input.focusPaneBeforeOpen && input.paneId) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
const tabId = openWorkspaceTabFocused(persistenceKey, target);
|
||||
const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
@@ -2706,23 +2791,32 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||
},
|
||||
onOpenWorkspaceFile: (filePath) => {
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
|
||||
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
handleOpenFileFromChat({ filePath });
|
||||
if (request.disposition === "side") {
|
||||
handleOpenFileFromChatInSidePane({
|
||||
location: request.location,
|
||||
sourcePaneId: input.paneId ?? undefined,
|
||||
parentTabId: input.tab.tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId });
|
||||
},
|
||||
onOpenImportSheet: openImportSheet,
|
||||
}),
|
||||
[
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
handleOpenFileFromChatInSidePane,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openImportSheet,
|
||||
openWorkspaceTabFocused,
|
||||
openWorkspaceChildTabFocused,
|
||||
persistenceKey,
|
||||
convertWorkspaceDraftToAgent,
|
||||
retargetWorkspaceTab,
|
||||
@@ -3213,7 +3307,10 @@ function WorkspaceScreenContent({
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||
<GestureDetector
|
||||
gesture={explorerOpenGesture}
|
||||
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
|
||||
>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { buildDeterministicWorkspaceTabId } from "@/utils/workspace-tab-identity";
|
||||
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
|
||||
|
||||
export interface WorkspaceTabModel {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AgentRuntimeInfo {
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
modeId?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
} from "@/workspace-tabs/identity";
|
||||
|
||||
export interface SplitPane {
|
||||
id: string;
|
||||
@@ -26,6 +26,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro
|
||||
export interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string | null;
|
||||
parentTabIdByTabId?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SplitPaneInternal extends SplitPane {
|
||||
@@ -903,10 +904,16 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
||||
collectAllPanes(root)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
|
||||
return {
|
||||
const normalizedLayout = {
|
||||
root,
|
||||
focusedPaneId: resolvedFocusedPaneId,
|
||||
};
|
||||
const parentTabIdByTabId = normalizeParentTabMap({
|
||||
raw: rawLayout.parentTabIdByTabId,
|
||||
openTabIds: new Set(collectAllTabs(root).map((tab) => tab.tabId)),
|
||||
});
|
||||
|
||||
return parentTabIdByTabId ? { ...normalizedLayout, parentTabIdByTabId } : normalizedLayout;
|
||||
}
|
||||
|
||||
export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null {
|
||||
@@ -1040,30 +1047,57 @@ function insertNewTabIntoFocusedPane(input: {
|
||||
|
||||
return {
|
||||
tabId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: insertTabIntoPane(layout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: nextTab,
|
||||
focusTabId: input.focus ? tabId : preservedFocusTabId,
|
||||
}),
|
||||
focusedPaneId: input.focus ? focusedPane.id : layout.focusedPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
|
||||
const targetTabId = buildDeterministicWorkspaceTabId(target);
|
||||
return (
|
||||
collectAllTabs(root).find(
|
||||
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, target),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function updateExistingTabTarget(
|
||||
layout: WorkspaceLayout,
|
||||
tab: WorkspaceTab,
|
||||
target: WorkspaceTabTarget,
|
||||
): WorkspaceLayout {
|
||||
if (workspaceTabTargetsEqual(tab.target, target)) {
|
||||
return layout;
|
||||
}
|
||||
return withNormalizedParentTabMap({
|
||||
...layout,
|
||||
root: replaceTabInTree(asInternalNode(layout.root), {
|
||||
tabId: tab.tabId,
|
||||
nextTabId: tab.tabId,
|
||||
target,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||
if (existingTab) {
|
||||
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
layout: nextLayout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
}) ?? nextLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1072,11 +1106,12 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
|
||||
|
||||
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||
if (existingTab) {
|
||||
return { tabId: existingTab.tabId, layout: input.layout };
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout: updateExistingTabTarget(input.layout, existingTab, input.target),
|
||||
};
|
||||
}
|
||||
|
||||
return insertNewTabIntoFocusedPane({ ...input, focus: false });
|
||||
@@ -1089,20 +1124,42 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeSuccessorTabId = getCloseSuccessorTabId({
|
||||
pane,
|
||||
tabId: input.tabId,
|
||||
openTabIds: new Set(collectAllTabs(internalLayout.root).map((tab) => tab.tabId)),
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
|
||||
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
||||
const nextFocusedPaneId =
|
||||
internalLayout.focusedPaneId === null
|
||||
? null
|
||||
: (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||
collectAllPanes(nextRoot)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
const parentTabIdByTabId = normalizeParentTabMap({
|
||||
raw: input.layout.parentTabIdByTabId,
|
||||
openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)),
|
||||
});
|
||||
const nextFocusedPaneId = getFocusedPaneIdAfterTabClose({
|
||||
root: nextRoot,
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
fallbackPaneId,
|
||||
});
|
||||
|
||||
return {
|
||||
const nextLayout = {
|
||||
root: nextRoot,
|
||||
focusedPaneId: nextFocusedPaneId,
|
||||
};
|
||||
const nextLayoutWithParentMap = parentTabIdByTabId
|
||||
? { ...nextLayout, parentTabIdByTabId }
|
||||
: nextLayout;
|
||||
|
||||
if (closeSuccessorTabId && findPaneContainingTab(nextRoot, closeSuccessorTabId)) {
|
||||
const focusedLayout =
|
||||
focusTabInLayout({
|
||||
layout: nextLayoutWithParentMap,
|
||||
tabId: closeSuccessorTabId,
|
||||
}) ?? nextLayoutWithParentMap;
|
||||
return parentTabIdByTabId ? { ...focusedLayout, parentTabIdByTabId } : focusedLayout;
|
||||
}
|
||||
|
||||
return nextLayoutWithParentMap;
|
||||
}
|
||||
|
||||
export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null {
|
||||
@@ -1116,10 +1173,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: focusTabInPane(layout.root, pane.id, input.tabId),
|
||||
focusedPaneId: pane.id,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function retargetTabInLayout(
|
||||
@@ -1159,18 +1217,26 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
const nextTabId =
|
||||
currentTab?.target.kind === "draft"
|
||||
? input.tabId
|
||||
: buildDeterministicWorkspaceTabId(input.target);
|
||||
|
||||
return {
|
||||
// Preserve the existing tab id so draft->entity transitions keep the same
|
||||
// React key during the first render. Reconciliation can canonicalize later.
|
||||
tabId: input.tabId,
|
||||
layout: {
|
||||
// Preserve draft-origin tab ids so draft->entity transitions keep the same
|
||||
// React key during the first render. Non-draft retargets must take the new
|
||||
// target identity immediately so local tab state cannot masquerade as the
|
||||
// previous agent/terminal/file.
|
||||
tabId: nextTabId,
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: input.tabId,
|
||||
nextTabId,
|
||||
target: input.target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1209,14 +1275,15 @@ export function convertDraftToAgentInLayout(
|
||||
|
||||
return {
|
||||
tabId: canonicalTabId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1228,13 +1295,14 @@ export function reorderFocusedPaneTabsInLayout(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: updatePaneInTree(layout.root, {
|
||||
paneId: layout.focusedPaneId,
|
||||
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLayoutResult | null {
|
||||
@@ -1259,10 +1327,11 @@ export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLay
|
||||
|
||||
return {
|
||||
paneId: result.newPaneId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: result.root,
|
||||
focusedPaneId: result.newPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1316,10 +1385,11 @@ export function splitPaneEmptyInLayout(
|
||||
|
||||
return {
|
||||
paneId: newPaneId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: nextRoot,
|
||||
focusedPaneId: newPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1338,14 +1408,15 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: insertTabIntoPane(detached.root, {
|
||||
paneId: input.toPaneId,
|
||||
tab: detached.tab,
|
||||
focusTabId: input.tabId,
|
||||
}),
|
||||
focusedPaneId: input.toPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null {
|
||||
@@ -1355,21 +1426,23 @@ export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayou
|
||||
if (input.layout.focusedPaneId === input.paneId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: input.layout.root,
|
||||
focusedPaneId: input.paneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: updateGroupSizesInTree(layout.root, {
|
||||
groupId: input.groupId,
|
||||
sizes: input.sizes,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderPaneTabsInLayout(
|
||||
@@ -1380,13 +1453,14 @@ export function reorderPaneTabsInLayout(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: updatePaneInTree(layout.root, {
|
||||
paneId: input.paneId,
|
||||
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
@@ -1400,6 +1474,86 @@ function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeParentTabMap(input: {
|
||||
raw: unknown;
|
||||
openTabIds: ReadonlySet<string>;
|
||||
}): Record<string, string> | undefined {
|
||||
if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const next: Record<string, string> = {};
|
||||
for (const [rawChildId, rawParentId] of Object.entries(input.raw)) {
|
||||
const childId = trimNonEmpty(rawChildId);
|
||||
const parentId = trimNonEmpty(typeof rawParentId === "string" ? rawParentId : null);
|
||||
if (
|
||||
!childId ||
|
||||
!parentId ||
|
||||
childId === parentId ||
|
||||
!input.openTabIds.has(childId) ||
|
||||
!input.openTabIds.has(parentId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
next[childId] = parentId;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function normalizeLayoutParentTabMap(layout: WorkspaceLayout): Record<string, string> | undefined {
|
||||
return normalizeParentTabMap({
|
||||
raw: layout.parentTabIdByTabId,
|
||||
openTabIds: new Set(collectAllTabs(layout.root).map((tab) => tab.tabId)),
|
||||
});
|
||||
}
|
||||
|
||||
function withNormalizedParentTabMap(layout: WorkspaceLayout): WorkspaceLayout {
|
||||
const parentTabIdByTabId = normalizeLayoutParentTabMap(layout);
|
||||
return parentTabIdByTabId
|
||||
? { ...layout, parentTabIdByTabId }
|
||||
: { root: layout.root, focusedPaneId: layout.focusedPaneId };
|
||||
}
|
||||
|
||||
function getCloseSuccessorTabId(input: {
|
||||
pane: SplitPane;
|
||||
tabId: string;
|
||||
openTabIds: ReadonlySet<string>;
|
||||
parentTabIdByTabId?: Record<string, string>;
|
||||
}): string | null {
|
||||
if (input.pane.focusedTabId !== input.tabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabIndex = input.pane.tabIds.indexOf(input.tabId);
|
||||
const parentTabId = input.parentTabIdByTabId?.[input.tabId] ?? null;
|
||||
if (parentTabId && input.openTabIds.has(parentTabId)) {
|
||||
return parentTabId;
|
||||
}
|
||||
|
||||
return (
|
||||
input.pane.tabIds[tabIndex + 1] ??
|
||||
(tabIndex > 0 ? input.pane.tabIds[tabIndex - 1] : null) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getFocusedPaneIdAfterTabClose(input: {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string | null;
|
||||
fallbackPaneId: string | null;
|
||||
}): string | null {
|
||||
if (input.focusedPaneId === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
findPaneById(input.root, input.focusedPaneId)?.id ??
|
||||
(input.fallbackPaneId && findPaneById(input.root, input.fallbackPaneId)?.id) ??
|
||||
collectAllPanes(input.root)[0]?.id ??
|
||||
DEFAULT_PANE_ID
|
||||
);
|
||||
}
|
||||
|
||||
function isEntityTarget(
|
||||
target: WorkspaceTabTarget,
|
||||
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
||||
@@ -1430,7 +1584,7 @@ function openEntityTabWithoutFocusing(
|
||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||
|
||||
const tabId = buildDeterministicWorkspaceTabId(target);
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: insertTabIntoPane(internalLayout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: {
|
||||
@@ -1441,7 +1595,8 @@ function openEntityTabWithoutFocusing(
|
||||
focusTabId: focusedPane.focusedTabId ?? tabId,
|
||||
}),
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
interface EntityTabGroup {
|
||||
@@ -1620,14 +1775,15 @@ export function reconcileWorkspaceTabs(
|
||||
reconciledFocusedTabId = canonicalTabId;
|
||||
}
|
||||
if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) {
|
||||
nextLayout = {
|
||||
nextLayout = withNormalizedParentTabMap({
|
||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||
tabId: keeper.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target: group.target,
|
||||
}),
|
||||
focusedPaneId: nextLayout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: nextLayout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.tabId === keeper.tabId) {
|
||||
|
||||
@@ -353,6 +353,39 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates an existing file tab when opening the same path at a new line range", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
lineStart: 5,
|
||||
});
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
lineStart: 10,
|
||||
lineEnd: 12,
|
||||
});
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
||||
expect(secondTabId).toBe(firstTabId);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: "file_/repo/worktree/a.ts",
|
||||
target: {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
lineStart: 10,
|
||||
lineEnd: 12,
|
||||
},
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("openTabInBackground inserts a tab without stealing focus", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
@@ -396,6 +429,90 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(pane.focusedTabId).toBe(secondTabId);
|
||||
});
|
||||
|
||||
it("closing a focused middle tab selects the tab to its right", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const firstTabId = "draft-1";
|
||||
const closedTabId = "draft-2";
|
||||
const rightTabId = "draft-3";
|
||||
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[workspaceKey]: {
|
||||
root: createPane({
|
||||
id: "main",
|
||||
tabIds: [firstTabId, closedTabId, rightTabId],
|
||||
focusedTabId: closedTabId,
|
||||
}),
|
||||
focusedPaneId: "main",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
workspaceLayoutStore.getState().closeTab(workspaceKey, closedTabId);
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(pane.tabIds).toEqual([firstTabId, rightTabId]);
|
||||
expect(pane.focusedTabId).toBe(rightTabId);
|
||||
});
|
||||
|
||||
it("closing a focused child tab returns to its parent before using tab-strip order", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const parentTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
draftId: "draft-parent",
|
||||
});
|
||||
const childTabId = store.openChildTabFocused(
|
||||
workspaceKey,
|
||||
{ kind: "draft", draftId: "draft-child" },
|
||||
parentTabId!,
|
||||
);
|
||||
const rightTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
draftId: "draft-right",
|
||||
});
|
||||
store.focusTab(workspaceKey, childTabId!);
|
||||
|
||||
workspaceLayoutStore.getState().closeTab(workspaceKey, childTabId!);
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(pane.tabIds).toEqual([parentTabId, rightTabId]);
|
||||
expect(pane.focusedTabId).toBe(parentTabId);
|
||||
});
|
||||
|
||||
it("closing a focused last tab selects the tab to its left", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const leftTabId = "draft-1";
|
||||
const closedTabId = "draft-2";
|
||||
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[workspaceKey]: {
|
||||
root: createPane({
|
||||
id: "main",
|
||||
tabIds: [leftTabId, closedTabId],
|
||||
focusedTabId: closedTabId,
|
||||
}),
|
||||
focusedPaneId: "main",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
workspaceLayoutStore.getState().closeTab(workspaceKey, closedTabId);
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(pane.tabIds).toEqual([leftTabId]);
|
||||
expect(pane.focusedTabId).toBe(leftTabId);
|
||||
});
|
||||
|
||||
it("unfocuses and restores the previous focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
@@ -589,6 +706,32 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargetTab gives a non-draft tab the new target identity", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const agentTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "agent",
|
||||
agentId: "agent-retarget",
|
||||
});
|
||||
const nextTabId = store.retargetTab(workspaceKey, agentTabId!, {
|
||||
kind: "draft",
|
||||
draftId: "draft-from-agent",
|
||||
});
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(agentTabId).toBe("agent_agent-retarget");
|
||||
expect(nextTabId).toBe("draft-from-agent");
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["draft-from-agent"]);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: "draft-from-agent",
|
||||
target: { kind: "draft", draftId: "draft-from-agent" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
|
||||
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
type WorkspaceTabSnapshot,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
||||
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
|
||||
export { buildWorkspaceTabPersistenceKey };
|
||||
export {
|
||||
@@ -76,6 +76,11 @@ interface WorkspaceLayoutStore {
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openChildTabFocused: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
parentTabId: string,
|
||||
) => string | null;
|
||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
@@ -188,6 +193,31 @@ function withoutFocusRestoration(
|
||||
return { focusRestorationByWorkspace };
|
||||
}
|
||||
|
||||
function attachParentTab(input: {
|
||||
layout: WorkspaceLayout;
|
||||
childTabId: string | null;
|
||||
parentTabId: string | null;
|
||||
}): WorkspaceLayout {
|
||||
const childTabId = trimNonEmpty(input.childTabId);
|
||||
const parentTabId = trimNonEmpty(input.parentTabId);
|
||||
if (!childTabId || !parentTabId || childTabId === parentTabId) {
|
||||
return normalizeLayout(input.layout);
|
||||
}
|
||||
|
||||
const openTabIds = new Set(collectAllTabs(input.layout.root).map((tab) => tab.tabId));
|
||||
if (!openTabIds.has(childTabId) || !openTabIds.has(parentTabId)) {
|
||||
return normalizeLayout(input.layout);
|
||||
}
|
||||
|
||||
return normalizeLayout({
|
||||
...input.layout,
|
||||
parentTabIdByTabId: {
|
||||
...input.layout.parentTabIdByTabId,
|
||||
[childTabId]: parentTabId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkspaceLayoutStore(
|
||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||
) {
|
||||
@@ -230,6 +260,45 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openChildTabFocused: (workspaceKey, target, parentTabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedParentTabId = trimNonEmpty(parentTabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedParentTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openTabInLayoutFocused({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
target: normalizedTarget,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
const layout = attachParentTab({
|
||||
layout: result.layout,
|
||||
childTabId: result.tabId,
|
||||
parentTabId: normalizedParentTabId,
|
||||
});
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
: removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget.agentId,
|
||||
),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: layout,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openTabInBackground: (workspaceKey, target) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
|
||||
@@ -185,6 +185,96 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps draft setup on a retargeted tab", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const tabId = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
});
|
||||
if (!tabId) {
|
||||
throw new Error("Expected tab id");
|
||||
}
|
||||
expect(tabId).toBe("agent_agent-1");
|
||||
|
||||
useWorkspaceTabsStore.getState().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId,
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: "draft-replacement",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: { effort: "high" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({
|
||||
kind: "draft",
|
||||
draftId: "draft-replacement",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: { effort: "high" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates an existing draft tab when the setup changes", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const first = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
});
|
||||
const second = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: "draft-1",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]).toHaveLength(1);
|
||||
expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({
|
||||
kind: "draft",
|
||||
draftId: "draft-1",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceDraftTabSetup,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
} from "@/workspace-tabs/identity";
|
||||
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||
|
||||
export interface WorkspaceDraftTabSetup {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| { kind: "file"; path: string }
|
||||
| WorkspaceFileTabTarget
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
|
||||
export interface WorkspaceTab {
|
||||
@@ -140,7 +152,12 @@ function extractMigrationRawSources(persistedState: unknown): MigrationRawSource
|
||||
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||
const kind = typeof raw.kind === "string" ? raw.kind : null;
|
||||
if (kind === "draft" && typeof raw.draftId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "draft", draftId: raw.draftId });
|
||||
const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "draft",
|
||||
draftId: raw.draftId,
|
||||
...(setup ? { setup } : {}),
|
||||
});
|
||||
}
|
||||
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
||||
@@ -152,7 +169,12 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
|
||||
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
|
||||
}
|
||||
if (kind === "file" && typeof raw.path === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "file", path: raw.path });
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "file",
|
||||
path: raw.path,
|
||||
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
|
||||
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
|
||||
});
|
||||
}
|
||||
if (kind === "setup" && typeof raw.workspaceId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
||||
|
||||
40
packages/app/src/styles/syntax-token-styles.ts
Normal file
40
packages/app/src/styles/syntax-token-styles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
// Per-token color styles for syntax-highlighted code. Each value reads a
|
||||
// theme path so the Unistyles Babel plugin tracks the dependency and updates
|
||||
// native style values on theme changes — no React re-render of consumers.
|
||||
// Used by message code blocks (hot path), file preview, and the diff viewer.
|
||||
export const syntaxTokenStyles = StyleSheet.create((theme) => ({
|
||||
base: { color: theme.colors.foreground },
|
||||
keyword: { color: theme.colors.syntax.keyword },
|
||||
comment: { color: theme.colors.syntax.comment },
|
||||
string: { color: theme.colors.syntax.string },
|
||||
number: { color: theme.colors.syntax.number },
|
||||
literal: { color: theme.colors.syntax.literal },
|
||||
function: { color: theme.colors.syntax.function },
|
||||
definition: { color: theme.colors.syntax.definition },
|
||||
class: { color: theme.colors.syntax.class },
|
||||
type: { color: theme.colors.syntax.type },
|
||||
tag: { color: theme.colors.syntax.tag },
|
||||
attribute: { color: theme.colors.syntax.attribute },
|
||||
property: { color: theme.colors.syntax.property },
|
||||
variable: { color: theme.colors.syntax.variable },
|
||||
operator: { color: theme.colors.syntax.operator },
|
||||
punctuation: { color: theme.colors.syntax.punctuation },
|
||||
regexp: { color: theme.colors.syntax.regexp },
|
||||
escape: { color: theme.colors.syntax.escape },
|
||||
meta: { color: theme.colors.syntax.meta },
|
||||
heading: { color: theme.colors.syntax.heading },
|
||||
link: { color: theme.colors.syntax.link },
|
||||
}));
|
||||
|
||||
type SyntaxTokenStyleValue = (typeof syntaxTokenStyles)["base"];
|
||||
|
||||
// Accepts a plain string so diff tokens (server-typed `string | null`) and
|
||||
// @getpaseo/highlight tokens (typed `HighlightStyle | null`) share one path.
|
||||
// Unknown styles fall back to the base color.
|
||||
export function syntaxTokenStyleFor(style: string | null | undefined): SyntaxTokenStyleValue {
|
||||
if (!style) return syntaxTokenStyles.base;
|
||||
const indexed = syntaxTokenStyles as unknown as Record<string, SyntaxTokenStyleValue>;
|
||||
return indexed[style] ?? syntaxTokenStyles.base;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user