Compare commits

..

37 Commits

Author SHA1 Message Date
Mohamed Boudra
2f638fe6eb chore(release): cut 0.1.77 2026-05-18 09:22:18 +07:00
Mohamed Boudra
b8154aa72b Draft changelog for 0.1.77 2026-05-18 09:21:32 +07:00
Mohamed Boudra
a2d8ce07d6 Stop OpenCode probes from creating empty sessions (#1079)
* Avoid empty OpenCode sessions during metadata probes

* Stabilize supervised restart regression test
2026-05-18 09:58:34 +08:00
Mohamed Boudra
721f1ee8d3 Fix inline code file matching 2026-05-17 23:40:31 +07:00
Mohamed Boudra
b55aa042d4 Return to parent tabs on close 2026-05-17 23:06:39 +07:00
Mohamed Boudra
1f7fc232b3 Limit tool call coalescing to prepend boundary 2026-05-17 22:24:59 +07:00
Zexin Yuan
338a41991d feat: add independent TLS control for relay public endpoint (#1045)
* feat: add independent TLS control for relay public endpoint

Add PASEO_RELAY_PUBLIC_USE_TLS env var and publicUseTls persisted
config to separately control TLS for the client→relay pairing offer,
falling back to relayUseTls when unset. The daemon→relay connection
continues to use PASEO_RELAY_USE_TLS alone.

Enables self-hosting behind a TLS-terminating reverse proxy where
the internal path is plain ws:// but public clients need wss://.

* fix: use public relay TLS in daemon status

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-17 23:00:42 +08:00
Mohamed Boudra
ee44b536f5 Support line ranges in assistant file links (#1067) 2026-05-17 22:55:46 +08:00
Mohamed Boudra
efc9c2d345 Preserve tool call idempotency 2026-05-17 21:09:29 +07:00
Mohamed Boudra
c4e45af565 Coalesce prepended tool call history 2026-05-17 20:53:03 +07:00
Mohamed Boudra
e27734b218 Use surface3 for user message bubble
Drops the accentSubtle theme token in favor of an existing surface layer.
surface3 gives a clear step up from surface0 (chat bg) without the tinted
look the accent fill produced.
2026-05-17 19:41:49 +07:00
accx
686a25fb65 Fix Windows import session path matching (#1012) 2026-05-17 20:32:28 +08:00
Mohamed Boudra
0e3a78b308 Stabilize mobile sidebar close test (#1065) 2026-05-17 20:32:06 +08:00
ezra
edb0ba888a Make terminal scrollback configurable (#1021)
* Make terminal scrollback configurable

* Fix terminal scrollback lint violation

* Fix terminal runtime test environment split

* Fix terminal scrollback updates

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-17 19:58:35 +08:00
Mohamed Boudra
00e7ac9ec8 Fix OpenCode custom command hangs (#1063)
* Fix OpenCode slash command turns

* ci: install opencode in server-tests so real OpenCode e2e runs

Server tests previously only installed the Claude Code CLI, so every
opencode-*.real.e2e.test.ts self-skipped on CI. The custom-command and
turn-lifecycle real tests cover the OpenCode session SSE rewrite, so
add opencode-ai globally and let them run. Big Pickle is free so no
provider secrets needed.

* Fix OpenCode unit tests exposed by enabling opencode in CI

server-tests previously skipped every OpenCodeAgentClient test because
opencode-ai was not installed. Now that CI runs them, four real issues
surface:

- Drop unused mkdirSync import (was used on a code path 1812b1489
  refactored away on main; the rebase merged imports without rechecking
  usage). Fixes the lint job.
- Fix slash-command-timeout test passing four ctor args instead of three,
  which silently dropped the TestOpenCodeRuntime and ran against the real
  opencode runtime.
- Relax the listModels contextWindowMaxTokens assertion. Some providers
  routed through OpenCode (OpenAI in particular) don't expose a numeric
  context window; assert the type only when the field is present.
- Swallow late notifySubscribers calls once close() flips the closed
  flag and clear subscribers up front. The session-lifetime SSE stream
  introduced by the prior commit means a session.error arriving after
  the owning test has moved on can rejectCompletion on a deferred no
  one is awaiting, surfacing as an unhandled rejection in a downstream
  test file.
2026-05-17 19:51:20 +08:00
c4605
b3c272f720 Fix native diff row expansion (#940)
* Fix structured diff parsing for no-prefix paths

Git can emit diff --git path path when diff.noprefix is enabled. The structured diff parser only handled the default a/path b/path form, so checkout diff assembly could lose hunks for affected files and render expanded rows empty.

Normalize optional a/ and b/ prefixes in both app and server parsers, and cover the no-prefix header form with regression tests.

* Fix no-prefix diff path parsing

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-17 18:58:10 +08:00
Mohamed Boudra
bb5c3dae00 Reduce user bubble desaturation 2026-05-17 15:39:34 +07:00
Mohamed Boudra
ed7944cf9d Distinguish user message bubbles with accentSubtle fill
User bubbles previously used surface2, which blended into the surrounding
chat surface. Add an `accentSubtle` theme token (accent darkened, slightly
desaturated, low alpha) and use it for the bubble fill so user messages
read as distinct without shouting.

Fixes #986
2026-05-17 15:36:12 +07:00
Bolun Zhang
91d257e103 fix(app): widen host switcher popover (#981)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-17 08:27:06 +00:00
Myriad-Dreamin
e379707a68 Show diff file paths in a tooltip (#1061)
* fix: add file path titles to diff headers

* Show diff file paths in a tooltip

* Fix workspace layout hydration

* Revert "Fix workspace layout hydration"

This reverts commit 05c89909b7.
2026-05-17 16:02:29 +08:00
Mohamed Boudra
f6afe0d864 feat(app): syntax highlighting and copy button for chat code blocks (#1062)
Adds Lezer-based highlighting to fenced code blocks in assistant messages,
with a hover/tap copy button. Colors move into the theme as
`theme.colors.syntax`, so the chat blocks, file preview, and diff viewer
share one StyleSheet path — token color updates flow through the Unistyles
native ShadowRegistry with no React re-renders on the chat hot path.
2026-05-17 16:01:08 +08:00
ezra
952b58c0eb Fix Windows Android app scripts (#1058)
* Fix Windows Android app scripts

* ci: skip onnxruntime CUDA download
2026-05-17 14:06:42 +08:00
ezra
9324a5b67a feat(app): show mode icons in agent status menu (#1059) 2026-05-17 13:56:14 +08:00
Samuel K
ec646db844 fix(mcp): add missing schedule tools (update, logs, run-once) (#1032)
* fix(mcp): add missing schedule tools (update, logs, run-once)

Add three schedule MCP tools to close the parity gap between
CLI (`paseo schedule update/logs/run-once`) and MCP surface.

The WebSocket RPC handlers and ScheduleService methods already
exist (added in PR #694), but the MCP tools were never registered.

Tools added:
- update_schedule: update interval, prompt, name, maxRuns, etc.
- schedule_logs: retrieve run history for a schedule
- run_once_schedule: trigger immediate one-time execution

Includes 8 unit tests covering all three tools.

Fixes #1031

* Fix schedule MCP update handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-16 22:45:22 +08:00
Mohamed Boudra
1812b14898 Fix OpenCode session imports (#1055)
* Fix OpenCode session imports

* Update OpenCode session tests after import cleanup
2026-05-16 22:08:26 +08:00
Mohamed Boudra
1c38ffe0a9 Fix composer scrollbar resize loop (#1050) 2026-05-16 19:31:24 +08:00
Mohamed Boudra
f4a4e0c25c fix(ios-release): submit each iOS build to its own App Store version
The submit_review fastlane lane identified the just-uploaded build by
CFBundleVersion alone. In this project the production iOS profile resets
the build number per marketing version, so every release sits at build 2
and Spaceship::ConnectAPI::Build.all returns the whole TestFlight history.
The .find call picked an arbitrary match, which is how 0.1.76's binary got
attached to the 0.1.75 App Store version slot and submitted for review
under the wrong label.

The release workflow now forwards app_version and app_build_version from
the build_ios outputs into the fastlane step as env vars, and the Fastfile
filters builds by marketing version plus build number to pin the exact
binary. The standalone resubmit workflow takes the same identifiers as
optional workflow_dispatch inputs; with both unset it falls back to "most
recently uploaded iOS build" so manual reruns still work.

Pinning fastlane in the Gemfile to lock the Spaceship behavior this lane
depends on.
2026-05-16 16:46:10 +07:00
Mohamed Boudra
a7dd0da2aa app: use SyncedLoader for chat thinking indicator 2026-05-16 16:46:10 +07:00
Mohamed Boudra
618b4a30b1 Refactor chat file-link opens to a single disposition seam (#1038)
* Open chat file links in a side pane on cmd-click

* Add hover tooltips to file links showing cmd-click hint

* Collapse file-link open path to a single disposition seam

Replace the parallel "open in side" callback that was threaded
alongside the normal open callback at every layer between the chat
click handler and the workspace router. One callback now carries a
`OpenFileDisposition` ("main" | "side") so the modifier-key decision
lives at the leaf and the routing decision lives at the workspace
screen, with no branching in between.

* Give assistant file links a home
2026-05-16 17:27:20 +08:00
Mohamed Boudra
4b02daed8a Add slash commands for ending and restarting agents (#1034)
* Add slash commands for ending agents

* Stabilize slash command e2e submit

* Unslop client slash command draft setup

* Fix slash commands while agent is running

* Fix slash command submit race

* Reshape client slash command execution

* Fix slash command tab cleanup
2026-05-16 17:03:59 +08:00
Mohamed Boudra
667f441cc0 Fix mobile web gestures, DnD activation, iOS zoom, and Enter behavior (#1048)
* fix(app): require long-press for sidebar dnd on mobile

* fix(app): right-edge open gesture for explorer sidebar

* fix(app): left-edge open gesture for workspace sidebar

* fix(app): allow gesture children to scroll on web

* fix(app): lock viewport zoom on compact web

* fix(app): don't submit on enter in compact web composer

---------

Co-authored-by: jon <nikuscs@gmail.com>
2026-05-16 14:20:24 +08:00
Matan Bendix Shenhav
5762055213 nix: include home-manager profile paths when inheriting user PATH (#1040)
`inheritUserEnvironment` set the daemon's PATH to NixOS user/system
profiles only, missing `~/.nix-profile/bin` and
`~/.local/state/nix/profile/bin` where home-manager installs per-user
CLIs. Result: agents like claude/opencode installed via home-manager
were invisible to the daemon and provider checks failed with
"Provider 'X' is not available. Please ensure the CLI is installed."

Prepend both home-manager profile dirs to PATH when running as a real
user (cfg.user != "paseo"), and update the option doc accordingly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:13:57 +08:00
mehmet turac
675817f0b2 fix(cli): mention remote daemon host on ls connect failure (#1043) 2026-05-16 05:12:49 +00:00
nikuscs
35c582913d fix (app): mobile sidebar web interactions (#900)
* fix mobile sidebar web interactions

* fix(app): restore button role on sidebar project row

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-15 13:38:43 +00:00
Matan Bendix Shenhav
e309d82b41 nix: shrink daemon install with @vercel/nft tracing (#966)
* nix: share npmDeps FOD between paseo and paseo-desktop

The daemon and desktop derivations share package-lock.json, so their
npmDeps FODs contain byte-identical content. But Nix names the FOD
store path with the consuming pname prefix, so paseo-<v>-npm-deps and
paseo-desktop-<v>-npm-deps resolve to different store paths despite
identical bytes. Building both runs prefetch-npm-deps twice in
parallel; on a clean store this downloads the entire registry
(~1-2 GB of tarballs) twice over the wire.

Wire paseo through as a callPackage arg of the desktop drv and
inherit (paseo) npmDeps. One FOD, one fetch, one store path.

Override path becomes paseo.override { npmDepsHash = "..."; } — the
desktop drv picks up the overridden npmDeps transitively. Downstream
flakes that previously did paseo-desktop.override { npmDepsHash }
need to either drop the desktop-side override (recommended) or pass
the overridden daemon through as paseo-desktop.override { paseo }.

* nix: trace daemon runtime closure with @vercel/nft

The daemon Nix installPhase used to `cp -a node_modules $out/lib/paseo/`,
shipping every package in the hoisted root — Expo, React Native, Metro,
Electron, ML stacks, ~1700 third-party deps the daemon never `require()`s
at runtime. Result: ~2.6 GB store path for a daemon whose actual runtime
closure is a tiny fraction of that.

Replace it with static module-graph tracing via @vercel/nft (the same
library Vercel and Next.js use for serverless bundling). The new
installPhase runs `node scripts/trace-daemon.mjs`, which:

1. Calls nodeFileTrace on three entry points — cli/dist/index.js,
   server/dist/scripts/supervisor-entrypoint.js, and the forked
   server/dist/server/terminal/terminal-worker-process.js. The worker
   needs to be a separate entry because nft does not follow fork()
   process boundaries.
2. Unions the trace result with an explicit list of non-JS runtime
   inputs nft does not detect: shell-integration assets read via
   readFileSync, the Silero VAD ONNX model loaded by the sherpa
   provider, .env.example, the CLI shebang script, and node-pty's
   compiled prebuilt binary for the host platform.
3. Ignores cross-platform native variants we explicitly do not ship
   (sherpa-onnx-*, @mariozechner/clipboard-*) and node-fetch's
   optional `encoding` peer.

installPhase consumes the file list, copies each entry into
$out/lib/paseo/ preserving its repo-relative path, and wraps two bin
entries (paseo, paseo-server) via makeWrapper.

Verified end-to-end:
- daemon $out drops from ~2.6 GB to ~131 MB (≈95% reduction)
- paseo --version, paseo --help work from the new $out
- paseo-server boots, HTTP server reaches "Server listening" within
  ~40 ms, no missing-module errors in the bootstrap log
- speech features degrade gracefully when sherpa-onnx-* / model files
  are absent (the documented behaviour for the Nix build, unchanged)

@vercel/nft added to root devDependencies (1.5.0); used only at
build time by the Nix derivation.

* fix(nix): gitignore result

* fix(nix): refresh npm deps hash post-rebase

The conflict resolution during rebase kept the trace-daemon hash, but
the merged package-lock.json combines upstream's lockfile updates with
the @vercel/nft addition, so the hash needed to be recomputed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:08:06 +08:00
Mohamed Boudra
5ea68dcdfa docs: refine changelog entries for 0.1.76 2026-05-15 18:43:50 +07:00
paseo-ai[bot]
01eb1ad512 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-15 11:11:58 +00:00
150 changed files with 6632 additions and 1508 deletions

View File

@@ -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

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ build/
dist/
.next/
out/
result
# Environment variables
.env

View File

@@ -1,5 +1,43 @@
# Changelog
## 0.1.77 - 2026-05-18
### Added
- **Slash commands to end and restart an agent.**
- **Syntax highlighting for code blocks in chat.**
- **Copy button on code blocks in chat.**
- **Configurable terminal scrollback.** ([#1021](https://github.com/getpaseo/paseo/pull/1021) by [@32r4](https://github.com/32r4))
- Assistant file links open at a specific line range when one is included.
- Mode icons appear in the agent status menu. ([#1059](https://github.com/getpaseo/paseo/pull/1059) by [@32r4](https://github.com/32r4))
- MCP exposes schedule update, logs, and run-once tools. ([#1032](https://github.com/getpaseo/paseo/pull/1032) by [@skevetter](https://github.com/skevetter))
- 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. ([#1045](https://github.com/getpaseo/paseo/pull/1045) by [@yzx9](https://github.com/yzx9))
### Improved
- User messages now have a distinct bubble fill for clearer chat hierarchy.
- Closing a tab returns to its parent tab.
- Diff rows show the full file path on hover. ([#1061](https://github.com/getpaseo/paseo/pull/1061) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin))
- The CLI shows the remote daemon host when `ls` cannot connect. ([#1043](https://github.com/getpaseo/paseo/pull/1043) by [@mturac](https://github.com/mturac))
- Nix install of the daemon is smaller. ([#966](https://github.com/getpaseo/paseo/pull/966) by [@ixxie](https://github.com/ixxie))
- Nix install honors home-manager profile paths when inheriting the user PATH. ([#1040](https://github.com/getpaseo/paseo/pull/1040) by [@ixxie](https://github.com/ixxie))
### Fixed
- OpenCode probes no longer create empty sessions.
- OpenCode custom commands no longer hang.
- OpenCode session imports succeed across more environments.
- Native diff rows expand correctly. ([#940](https://github.com/getpaseo/paseo/pull/940) by [@bolasblack](https://github.com/bolasblack))
- Mobile sidebar interactions work correctly on web. ([#900](https://github.com/getpaseo/paseo/pull/900) by [@nikuscs](https://github.com/nikuscs))
- Mobile web drag gestures fire reliably. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
- Mobile web drag-and-drop activates correctly. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
- iOS Safari no longer zooms when focusing the composer. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
- Enter behavior in the mobile web composer is consistent. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
- Composer no longer flickers when resizing with long prompts.
- Inline code links in assistant messages open the correct file.
- Host switcher popover is wide enough to show host names. ([#981](https://github.com/getpaseo/paseo/pull/981) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
- Windows: importing existing sessions matches paths correctly. ([#1012](https://github.com/getpaseo/paseo/pull/1012) by [@kj1534](https://github.com/kj1534))
## 0.1.76 - 2026-05-15
### Added
@@ -16,13 +54,13 @@
- Claude Code now picks up models from `~/.claude/settings.json` so custom model lists show up in the model picker.
- Local Claude Code settings (`.claude/settings.local.json`) apply per workspace.
- Diagnostics for generic ACP providers surface in the model picker.
- MCP: control and discover provider features from external orchestrators. ([#909](https://github.com/getpaseo/paseo/pull/909), [#910](https://github.com/getpaseo/paseo/pull/910) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
- Allow setting fast mode for Paseo subagents ([#909](https://github.com/getpaseo/paseo/pull/909), [#910](https://github.com/getpaseo/paseo/pull/910) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
### Improved
- Surface Claude error messages in chat instead of ending the turn silently.
- Ref picker auto-selects when a single PR is attached.
- New workspace flow honors the currently checked-out branch when branching off. ([#908](https://github.com/getpaseo/paseo/pull/908) by [@sbtobb](https://github.com/sbtobb))
- Workspace checkout picker auto-selects when a single PR is attached.
- New workspace flow honors the currently checked-out branch when branching off. ([#909](https://github.com/getpaseo/paseo/pull/908) by [@sbtobb](https://github.com/sbtobb))
- OpenCode models from console subscription providers now appear in the model picker. ([#917](https://github.com/getpaseo/paseo/pull/917) by [@t2o2](https://github.com/t2o2))
- Cursor model picker reflects the models advertised by the Cursor ACP client. ([#958](https://github.com/getpaseo/paseo/pull/958) by [@chrisbanes](https://github.com/chrisbanes))

View File

@@ -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

View File

@@ -107,7 +107,7 @@ Enables remote access when the daemon is behind a firewall.
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- 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.

View File

@@ -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: {

View File

@@ -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

View File

@@ -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; };
}
);

View File

@@ -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.

View File

@@ -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;

View File

@@ -1 +1 @@
sha256-1NkvtCT9TelnukukDD+jkIbwxLbWiNeMbXtJIzhTzv0=
sha256-SRax+Frqf0OhDhfWICSlhOnHBGsoHiXQc6P+zo5VjN8=

View File

@@ -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 \

402
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.76",
"version": "0.1.77",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.76",
"version": "0.1.77",
"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",
@@ -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.77",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -39091,10 +39417,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.76",
"version": "0.1.77",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.76",
"@getpaseo/server": "0.1.77",
"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.77",
"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.77",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39222,7 +39548,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.76",
"version": "0.1.77",
"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.77",
"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.77",
"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.77",
"@getpaseo/relay": "0.1.77",
"@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.77",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.76",
"version": "0.1.77",
"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",

View File

@@ -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

View File

@@ -6,7 +6,16 @@ name: Resubmit iOS for App Store review
# 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

View File

@@ -1,3 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
gem "fastlane", "~> 2.234"

View 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);
},
);
});
});

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -1,16 +1,16 @@
{
"name": "@getpaseo/app",
"version": "0.1.76",
"version": "0.1.77",
"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",

View File

@@ -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>
);

View 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";

View 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,
},
}));

View File

@@ -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", () => {

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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");
}

View File

@@ -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) {

View 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",
});
});
});

View 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,
};
}

View File

@@ -16,7 +16,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
@@ -58,6 +58,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest";
import type { Theme } from "@/styles/theme";
import {
getFeatureHighlightColor,
getFeatureTooltip,
@@ -183,6 +184,21 @@ const MODE_ICONS = {
ShieldOff,
ShieldQuestionMark,
} as const;
const ThemedShieldCheck = withUnistyles(ShieldCheck);
const ThemedShieldAlert = withUnistyles(ShieldAlert);
const ThemedShieldOff = withUnistyles(ShieldOff);
const ThemedShieldQuestionMark = withUnistyles(ShieldQuestionMark);
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const MODE_MENU_LEADING_ICONS = {
ShieldCheck: <ThemedShieldCheck size={16} uniProps={foregroundColorMapping} />,
ShieldAlert: <ThemedShieldAlert size={16} uniProps={foregroundColorMapping} />,
ShieldOff: <ThemedShieldOff size={16} uniProps={foregroundColorMapping} />,
ShieldQuestionMark: <ThemedShieldQuestionMark size={16} uniProps={foregroundColorMapping} />,
} as const;
function alwaysTrue() {
return true;
@@ -1606,19 +1622,12 @@ function ModeMenuItem({
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 leadingIcon = visuals?.icon ? MODE_MENU_LEADING_ICONS[visuals.icon] : null;
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}

View File

@@ -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,
},
}));

View File

@@ -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],
);

View File

@@ -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]);

View File

@@ -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. */
@@ -807,6 +809,7 @@ export function Composer({
serverId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
@@ -907,6 +910,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 +952,8 @@ export function Composer({
serverId,
agentId,
draftConfig: commandDraftConfig,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
onAutocompleteApplied: () => {
messageInputRef.current?.focus();
},
@@ -1109,16 +1149,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 +1331,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),
[],
);

View File

@@ -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();

View File

@@ -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,

View File

@@ -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>

View 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,
},
}));

View File

@@ -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,

View File

@@ -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}
/>

View File

@@ -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,

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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 });

View File

@@ -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}

View File

@@ -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]);

View File

@@ -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;
}

View File

@@ -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>
);
});

View File

@@ -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({

View File

@@ -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;

View File

@@ -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(

View 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]);
}

View File

@@ -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(

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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}
/>

View File

@@ -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"> = {

View 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;
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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" });
});
});

View File

@@ -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 };
}

View File

@@ -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>
) : (

View File

@@ -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[];

View File

@@ -75,6 +75,7 @@ export interface AgentRuntimeInfo {
sessionId: string | null;
model?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
extra?: Record<string, unknown>;
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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 });

View 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;
}

View File

@@ -1,3 +1,5 @@
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
export const baseColors = {
// Base colors
white: "#ffffff",
@@ -518,6 +520,7 @@ function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColor
colors: {
...semanticColors,
palette: baseColors,
syntax: darkHighlightColors,
},
shadow: darkShadow,
...commonTheme,
@@ -535,6 +538,7 @@ export const lightTheme = {
colors: {
...lightSemanticColors,
palette: baseColors,
syntax: lightHighlightColors,
},
shadow: {
sm: {

View File

@@ -61,7 +61,11 @@ async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }):
}
}
function createTerminalHost(input: { width: number; height: number }): MountedTerminal {
function createTerminalHost(input: {
width: number;
height: number;
scrollback?: number;
}): MountedTerminal {
const root = document.createElement("div");
root.style.width = `${input.width}px`;
root.style.height = `${input.height}px`;
@@ -101,6 +105,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
root,
host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000,
theme: {
background: "#0b0b0b",
foreground: "#e6e6e6",
@@ -163,6 +168,32 @@ afterEach(() => {
});
describe("terminal emulator runtime in a real browser", () => {
it("passes configured scrollback to xterm", async () => {
await page.viewport(900, 600);
createTerminalHost({ width: 720, height: 360, scrollback: 42_000 });
await waitFor({
predicate: () => window.__paseoTerminal !== undefined,
});
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
});
it("updates scrollback on the mounted xterm", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360, scrollback: 10_000 });
await waitFor({
predicate: () => window.__paseoTerminal !== undefined,
});
const terminal = window.__paseoTerminal;
mounted.runtime.setScrollback({ lines: 42_000 });
expect(window.__paseoTerminal).toBe(terminal);
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
});
it("reports a larger PTY size when the terminal container grows", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 360, height: 180 });

View File

@@ -49,9 +49,29 @@ vi.mock("@xterm/addon-webgl", () => ({
},
}));
const terminalConstructorOptions = vi.hoisted(() => ({
values: [] as unknown[],
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class Terminal {
rows = 24;
cols = 80;
unicode = { activeVersion: "" };
parser = {
registerCsiHandler: () => undefined,
};
constructor(options: unknown) {
terminalConstructorOptions.values.push(options);
}
loadAddon(): void {}
open(): void {}
onData(): { dispose: () => void } {
return { dispose: () => undefined };
}
attachCustomKeyEventHandler(): void {}
dispose(): void {}
refresh(): void {}
},
}));
@@ -63,7 +83,7 @@ interface StubTerminal {
resize?: (cols: number, rows: number) => void;
focus: () => void;
refresh?: (start: number, end: number) => void;
options?: { theme?: unknown };
options?: { theme?: unknown; scrollback?: number };
rows?: number;
cols?: number;
}
@@ -118,6 +138,7 @@ describe("terminal-emulator-runtime", () => {
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
__paseoTerminal: undefined,
};
terminalConstructorOptions.values = [];
});
afterEach(() => {
@@ -317,6 +338,26 @@ describe("terminal-emulator-runtime", () => {
expect(refresh).toHaveBeenCalledWith(0, 11);
});
it("updates terminal scrollback without remounting", () => {
const runtime = new TerminalEmulatorRuntime();
const refresh = vi.fn();
const terminal: StubTerminal = {
write: () => {},
reset: () => {},
focus: () => {},
refresh,
options: { scrollback: 10_000 },
rows: 12,
cols: 40,
};
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
runtime.setScrollback({ lines: 42_000 });
expect(terminal.options?.scrollback).toBe(42_000);
expect(refresh).toHaveBeenCalledWith(0, 11);
});
it("forces a refit when the page becomes visible again", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();

View File

@@ -27,6 +27,7 @@ export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
initialSnapshot: TerminalState | null;
scrollback: number;
theme: ITheme;
}
@@ -178,7 +179,7 @@ export class TerminalEmulatorRuntime {
overviewRuler: {
width: 8,
},
scrollback: 10_000,
scrollback: input.scrollback,
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
@@ -578,6 +579,22 @@ export class TerminalEmulatorRuntime {
this.refreshVisibleRows();
}
setScrollback(input: { lines: number }): void {
const terminal = this.terminal;
if (!terminal) {
return;
}
try {
terminal.options.scrollback = input.lines;
} catch {
// ignore
return;
}
this.refreshVisibleRows();
}
focus(): void {
this.terminal?.focus();
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { StreamItem } from "@/types/stream";
import { hydrateStreamState, type AgentToolCallItem, type StreamItem } from "@/types/stream";
import {
createAgentStreamReducerQueue,
processTimelineResponse,
@@ -31,6 +31,28 @@ function makeTimelineEntry(
};
}
function makeToolCallTimelineEntry(
seq: number,
callId: string,
status: "running" | "completed",
detail: Record<string, unknown>,
) {
return {
seqStart: seq,
seqEnd: seq,
provider: "claude",
item: {
type: "tool_call",
callId,
name: "Read",
status,
detail,
error: null,
},
timestamp: new Date(1000 + seq).toISOString(),
};
}
function makeTimelineEvent(
text: string,
type: string = "assistant_message",
@@ -97,6 +119,13 @@ function getUserTexts(items: StreamItem[]): string[] {
.map((item) => item.text);
}
function getAgentToolCalls(items: StreamItem[]) {
return items.filter(
(item): item is AgentToolCallItem =>
item.kind === "tool_call" && item.payload.source === "agent",
);
}
const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
@@ -510,6 +539,146 @@ describe("processTimelineResponse", () => {
});
});
it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => {
const callId = "toolu_boundary";
const currentTail = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(3, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(3000),
},
],
{ source: "canonical" },
);
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
makeToolCallTimelineEntry(1, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
],
},
});
const toolCalls = getAgentToolCalls(result.tail);
expect(
toolCalls.map((item) => ({
id: item.id,
callId: item.payload.data.callId,
status: item.payload.data.status,
detailType: item.payload.data.detail.type,
})),
).toEqual([
{
id: `agent_tool_${callId}`,
callId,
status: "completed",
detailType: "read",
},
]);
});
it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => {
const callId = "toolu_not_boundary";
const currentTail = hydrateStreamState(
[
{
event: makeTimelineEvent("current chunk"),
timestamp: new Date(3000),
},
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(4, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(4000),
},
],
{ source: "canonical" },
);
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
makeToolCallTimelineEntry(1, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
makeTimelineEntry(2, "older chunk "),
],
},
});
expect(
result.tail.map((item) => ({
kind: item.kind,
id: item.id,
status: item.kind === "tool_call" ? item.payload.data.status : null,
text: item.kind === "assistant_message" ? item.text : null,
})),
).toEqual([
{
kind: "tool_call",
id: `agent_tool_${callId}`,
status: "running",
text: null,
},
{
kind: "assistant_message",
id: expect.any(String),
status: null,
text: "older chunk current chunk",
},
{
kind: "tool_call",
id: `agent_tool_${callId}`,
status: "completed",
text: null,
},
]);
});
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",

View File

@@ -3,7 +3,13 @@ import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
applyStreamEvent,
hydrateStreamState,
isAgentToolCallItem,
mergeAgentToolCallItem,
reduceStreamUpdate,
} from "@/types/stream";
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
@@ -346,6 +352,21 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
const olderLast = olderTail.at(-1);
const currentFirst = currentTail[0];
if (
olderLast &&
currentFirst &&
isAgentToolCallItem(olderLast) &&
isAgentToolCallItem(currentFirst) &&
olderLast.payload.data.callId === currentFirst.payload.data.callId
) {
return [
...olderTail.slice(0, -1),
mergeAgentToolCallItem(olderLast, currentFirst.payload.data, currentFirst.timestamp),
...currentTail.slice(1),
];
}
if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") {
return [...olderTail, ...currentTail];
}

View File

@@ -0,0 +1,11 @@
// The runtime sets `sourceInfo` on fence/code nodes (tokensToAST.js sets
// `sourceInfo: token.info`), but the shipped .d.ts omits the field. The
// re-export is what marks this file as a module so the `declare module`
// below augments the package instead of shadowing it.
export type { ASTNode } from "react-native-markdown-display";
declare module "react-native-markdown-display" {
interface ASTNode {
sourceInfo?: string;
}
}

View File

@@ -438,6 +438,36 @@ function mergeAgentToolCallStatus(
return "running";
}
export function mergeAgentToolCallItem(
existing: AgentToolCallItem,
data: AgentToolCallData,
timestamp: Date,
): AgentToolCallItem {
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status);
const mergedError =
mergedStatus === "failed"
? (data.error ?? existing.payload.data.error ?? { message: "Tool call failed" })
: null;
const mergedMetadata = mergeToolCallMetadata(existing.payload.data.metadata, data.metadata);
const mergedDetail = mergeToolCallDetail(existing.payload.data.detail, data.detail);
return {
...existing,
timestamp,
payload: {
source: "agent",
data: {
...existing.payload.data,
...data,
status: mergedStatus,
error: mergedError,
detail: mergedDetail,
metadata: mergedMetadata,
},
},
};
}
function appendAgentToolCall(
state: StreamItem[],
data: AgentToolCallData,
@@ -450,42 +480,22 @@ function appendAgentToolCall(
if (!existing || !isAgentToolCallItem(existing)) {
return state;
}
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status);
const mergedError =
mergedStatus === "failed"
? (data.error ?? existing.payload.data.error ?? { message: "Tool call failed" })
: null;
const mergedMetadata = mergeToolCallMetadata(existing.payload.data.metadata, data.metadata);
const mergedDetail = mergeToolCallDetail(existing.payload.data.detail, data.detail);
const merged = mergeAgentToolCallItem(existing, data, timestamp);
if (
data.provider === existing.payload.data.provider &&
data.callId === existing.payload.data.callId &&
data.name === existing.payload.data.name &&
mergedStatus === existing.payload.data.status &&
mergedError === existing.payload.data.error &&
mergedDetail === existing.payload.data.detail &&
mergedMetadata === existing.payload.data.metadata
merged.payload.data.provider === existing.payload.data.provider &&
merged.payload.data.callId === existing.payload.data.callId &&
merged.payload.data.name === existing.payload.data.name &&
merged.payload.data.status === existing.payload.data.status &&
merged.payload.data.error === existing.payload.data.error &&
merged.payload.data.detail === existing.payload.data.detail &&
merged.payload.data.metadata === existing.payload.data.metadata
) {
return state;
}
const next = [...state];
next[existingIndex] = {
...existing,
timestamp,
payload: {
source: "agent",
data: {
...existing.payload.data,
...data,
status: mergedStatus,
error: mergedError,
detail: mergedDetail,
metadata: mergedMetadata,
},
},
};
next[existingIndex] = merged;
return next;
}

View File

@@ -185,6 +185,58 @@ describe("parseDiff", () => {
expect(hunk.lines[3].content).toBe("const bar = 3;");
});
it("parses no-prefix git diff headers", () => {
const files = parseDiff(
SIMPLE_DIFF.replace("a/example.ts b/example.ts", "example.ts example.ts")
.replace("--- a/example.ts", "--- example.ts")
.replace("+++ b/example.ts", "+++ example.ts"),
);
expect(files).toHaveLength(1);
expect(files[0].path).toBe("example.ts");
expect(files[0].additions).toBe(1);
expect(files[0].deletions).toBe(1);
expect(files[0].hunks).toHaveLength(1);
});
it("parses paths with spaces", () => {
const files = parseDiff(SIMPLE_DIFF.replaceAll("example.ts", "file with space.ts"));
expect(files).toHaveLength(1);
expect(files[0].path).toBe("file with space.ts");
expect(files[0].hunks).toHaveLength(1);
});
it("parses no-prefix paths with spaces", () => {
const files = parseDiff(
SIMPLE_DIFF.replaceAll("example.ts", "file with space.ts")
.replace(
"a/file with space.ts b/file with space.ts",
"file with space.ts file with space.ts",
)
.replace("--- a/file with space.ts", "--- file with space.ts")
.replace("+++ b/file with space.ts", "+++ file with space.ts"),
);
expect(files).toHaveLength(1);
expect(files[0].path).toBe("file with space.ts");
expect(files[0].hunks).toHaveLength(1);
});
it("preserves no-prefix paths that start with a or b", () => {
const files = parseDiff(
`${SIMPLE_DIFF.replace(
"diff --git a/example.ts b/example.ts",
"diff --git a/example.ts a/example.ts",
).replace("--- a/example.ts\n+++ b/example.ts", "--- a/example.ts\n+++ a/example.ts")}
${SIMPLE_DIFF.replaceAll("example.ts", "other.ts")
.replace("diff --git a/other.ts b/other.ts", "diff --git b/other.ts b/other.ts")
.replace("--- a/other.ts\n+++ b/other.ts", "--- b/other.ts\n+++ b/other.ts")}`,
);
expect(files.map((file) => file.path)).toEqual(["a/example.ts", "b/other.ts"]);
});
it("parses a diff with multiple hunks", () => {
const files = parseDiff(MULTI_HUNK_DIFF);

View File

@@ -30,14 +30,40 @@ function isDiffMetadataLine(line: string): boolean {
return DIFF_METADATA_PREFIXES.some((prefix) => line.startsWith(prefix));
}
function extractDiffPath(firstLine: string): string {
const pathMatch = firstLine.match(/a\/(.*?) b\//);
if (pathMatch) {
return pathMatch[1];
// Git's default patch headers use paired a/path and b/path prefixes, while
// diff.noprefix emits plain paths that may legitimately start with a/ or b/.
function usesDiffPathPrefixes(oldPath: string, newPath: string): boolean {
return oldPath.startsWith("a/") && newPath.startsWith("b/");
}
function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): string | null {
const line = lines.find((candidate) => candidate.startsWith(prefix));
if (!line) {
return null;
}
const newFileMatch = firstLine.match(/b\/(.+)$/);
if (newFileMatch) {
return newFileMatch[1];
const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
return path === "/dev/null" ? null : path;
}
function extractDiffPath(lines: string[]): string {
const firstLine = lines[0] ?? "";
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
return metadataPath;
}
const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/);
if (pathMatch) {
const [, oldPath, newPath] = pathMatch;
const path = newPath === "/dev/null" ? oldPath : newPath;
return usesDiffPathPrefixes(oldPath, newPath) ? path.slice(2) : path;
}
return "unknown";
}
@@ -118,11 +144,10 @@ export function parseDiff(diffText: string): ParsedDiffFile[] {
for (const section of fileSections) {
const lines = section.split("\n");
const firstLine = lines[0];
const isNew = section.includes("new file mode") || section.includes("--- /dev/null");
const isDeleted = section.includes("deleted file mode") || section.includes("+++ /dev/null");
const path = extractDiffPath(firstLine);
const path = extractDiffPath(lines);
const { hunks, additions, deletions } = parseHunks(lines);
files.push({ path, isNew, isDeleted, additions, deletions, hunks });

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { getWorkingIndicatorDotStrength, WORKING_INDICATOR_OFFSETS } from "./working-indicator";
describe("getWorkingIndicatorDotStrength", () => {
it("returns a stable triangular pulse over one cycle", () => {
expect(getWorkingIndicatorDotStrength(0, 0)).toBe(0);
expect(getWorkingIndicatorDotStrength(0.125, 0)).toBe(0.25);
expect(getWorkingIndicatorDotStrength(0.25, 0)).toBe(0.5);
expect(getWorkingIndicatorDotStrength(0.5, 0)).toBe(1);
expect(getWorkingIndicatorDotStrength(0.75, 0)).toBe(0.5);
expect(getWorkingIndicatorDotStrength(1, 0)).toBe(0);
});
it("keeps the dots phase-shifted instead of identical", () => {
const strengths = WORKING_INDICATOR_OFFSETS.map((offset) =>
getWorkingIndicatorDotStrength(0, offset),
);
expect(strengths).toEqual([0, 0.26666666666666666, 0.5333333333333333]);
});
it("wraps progress cleanly across loop boundaries", () => {
expect(getWorkingIndicatorDotStrength(1.1, 0)).toBeCloseTo(
getWorkingIndicatorDotStrength(0.1, 0),
);
expect(getWorkingIndicatorDotStrength(-0.1, 0)).toBeCloseTo(
getWorkingIndicatorDotStrength(0.9, 0),
);
});
});

View File

@@ -1,21 +0,0 @@
export const WORKING_INDICATOR_CYCLE_MS = 1200;
export const WORKING_INDICATOR_OFFSETS = [
0,
160 / WORKING_INDICATOR_CYCLE_MS,
320 / WORKING_INDICATOR_CYCLE_MS,
] as const;
function normalizePhase(value: number): number {
"worklet";
const wrapped = value % 1;
return wrapped < 0 ? wrapped + 1 : wrapped;
}
export function getWorkingIndicatorDotStrength(progress: number, offset: number): number {
"worklet";
const phase = normalizePhase(progress + offset);
if (phase <= 0.5) {
return phase * 2;
}
return (1 - phase) * 2;
}

View File

@@ -1,89 +0,0 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
export function normalizeWorkspaceTabTarget(
value: WorkspaceTabTarget | null | undefined,
): WorkspaceTabTarget | null {
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
return null;
}
if (value.kind === "draft") {
const draftId = trimNonEmpty(value.draftId);
return draftId ? { kind: "draft", draftId } : null;
}
if (value.kind === "agent") {
const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "agent", agentId } : null;
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
}
if (value.kind === "browser") {
const browserId = trimNonEmpty(value.browserId);
return browserId ? { kind: "browser", browserId } : null;
}
if (value.kind === "file") {
const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId } : null;
}
return null;
}
export function workspaceTabTargetsEqual(
left: WorkspaceTabTarget,
right: WorkspaceTabTarget,
): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "draft" && right.kind === "draft") {
return left.draftId === right.draftId;
}
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "browser" && right.kind === "browser") {
return left.browserId === right.browserId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
}
return false;
}
export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") {
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
if (target.kind === "browser") {
return `browser_${target.browserId}`;
}
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}
return `file_${target.path}`;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

View File

@@ -0,0 +1,167 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
export function normalizeWorkspaceTabTarget(
value: WorkspaceTabTarget | null | undefined,
): WorkspaceTabTarget | null {
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
return null;
}
if (value.kind === "draft") {
const draftId = trimNonEmpty(value.draftId);
if (!draftId) {
return null;
}
const setup = normalizeWorkspaceDraftTabSetup(value.setup);
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
}
if (value.kind === "agent") {
const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "agent", agentId } : null;
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
}
if (value.kind === "browser") {
const browserId = trimNonEmpty(value.browserId);
return browserId ? { kind: "browser", browserId } : null;
}
if (value.kind === "file") {
return normalizeFileTabTarget(value);
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId } : null;
}
return null;
}
export function normalizeWorkspaceDraftTabSetup(
value: unknown,
): WorkspaceDraftTabSetup | undefined {
const record = isPlainRecord(value) ? value : null;
if (!record) {
return undefined;
}
const provider = trimNonEmpty(typeof record.provider === "string" ? record.provider : null);
const cwd = trimNonEmpty(typeof record.cwd === "string" ? record.cwd : null);
if (!provider || !cwd) {
return undefined;
}
return {
provider,
cwd,
modeId: trimOptionalString(typeof record.modeId === "string" ? record.modeId : null),
model: trimOptionalString(typeof record.model === "string" ? record.model : null),
thinkingOptionId: trimOptionalString(
typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null,
),
featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {},
};
}
export function workspaceTabTargetsEqual(
left: WorkspaceTabTarget,
right: WorkspaceTabTarget,
): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "draft" && right.kind === "draft") {
return left.draftId === right.draftId && workspaceDraftTabSetupsEqual(left.setup, right.setup);
}
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "browser" && right.kind === "browser") {
return left.browserId === right.browserId;
}
if (left.kind === "file" && right.kind === "file") {
return workspaceFileLocationsEqual(left, right);
}
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
}
return false;
}
function workspaceDraftTabSetupsEqual(
left: WorkspaceDraftTabSetup | undefined,
right: WorkspaceDraftTabSetup | undefined,
): boolean {
if (!left || !right) {
return left === right;
}
return (
left.provider === right.provider &&
left.cwd === right.cwd &&
left.modeId === right.modeId &&
left.model === right.model &&
left.thinkingOptionId === right.thinkingOptionId &&
recordsShallowEqual(left.featureValues, right.featureValues)
);
}
function recordsShallowEqual(
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean {
const leftKeys = Object.keys(left);
if (leftKeys.length !== Object.keys(right).length) {
return false;
}
for (const key of leftKeys) {
if (!Object.hasOwn(right, key) || !Object.is(left[key], right[key])) {
return false;
}
}
return true;
}
export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") {
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
if (target.kind === "browser") {
return `browser_${target.browserId}`;
}
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}
return `file_${target.path}`;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeFileTabTarget(
value: Extract<WorkspaceTabTarget, { kind: "file" }>,
): WorkspaceTabTarget | null {
const location = normalizeWorkspaceFileLocation(value);
return location ? { kind: "file", ...location } : null;
}
function trimOptionalString(value: string | null | undefined): string | null {
return value == null ? null : trimNonEmpty(value);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
workspaceFileLocationsEqual,
} from ".";
describe("normalizeWorkspaceFileLocation", () => {
it("normalizes paths and valid line ranges", () => {
expect(
normalizeWorkspaceFileLocation({
path: "src\\app.ts",
lineStart: 12.8,
lineEnd: 20.2,
}),
).toEqual({
path: "src/app.ts",
lineStart: 12,
lineEnd: 20,
});
});
it("drops invalid or backwards line ranges", () => {
expect(normalizeWorkspaceFileLocation({ path: "src/app.ts", lineStart: -1 })).toEqual({
path: "src/app.ts",
});
expect(
normalizeWorkspaceFileLocation({ path: "src/app.ts", lineStart: 20, lineEnd: 12 }),
).toEqual({
path: "src/app.ts",
lineStart: 20,
});
});
it("rejects empty paths", () => {
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
});
});
describe("workspace file tab targets", () => {
it("keeps file tab identity separate from line selection", () => {
expect(createWorkspaceFileTabTarget({ path: "src/app.ts", lineStart: 12 })).toEqual({
kind: "file",
path: "src/app.ts",
lineStart: 12,
});
});
it("compares full location equality", () => {
expect(
workspaceFileLocationsEqual(
{ path: "src/app.ts", lineStart: 12 },
{ path: "src/app.ts", lineStart: 12 },
),
).toBe(true);
expect(
workspaceFileLocationsEqual(
{ path: "src/app.ts", lineStart: 12 },
{ path: "src/app.ts", lineStart: 13 },
),
).toBe(false);
});
});

View File

@@ -0,0 +1,59 @@
export type OpenFileDisposition = "main" | "side";
export interface WorkspaceFileLocation {
path: string;
lineStart?: number;
lineEnd?: number;
}
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
export interface WorkspaceFileOpenRequest {
location: WorkspaceFileLocation;
disposition: OpenFileDisposition;
}
export function normalizeWorkspaceFileLocation(
location: WorkspaceFileLocation | null | undefined,
): WorkspaceFileLocation | null {
if (!location) {
return null;
}
const path = location.path.trim().replace(/\\/g, "/");
if (!path) {
return null;
}
const lineStart = normalizeLineNumber(location.lineStart);
const lineEnd = normalizeLineNumber(location.lineEnd);
return {
path,
...(lineStart ? { lineStart } : {}),
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
export function workspaceFileLocationsEqual(
left: WorkspaceFileLocation,
right: WorkspaceFileLocation,
): boolean {
return (
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
);
}
export function createWorkspaceFileTabTarget(
location: WorkspaceFileLocation,
): WorkspaceFileTabTarget {
return {
kind: "file",
...location,
};
}
function normalizeLineNumber(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}

View File

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

View File

@@ -178,7 +178,8 @@ export async function runLsCommand(
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
details:
"Start the daemon with: paseo daemon start\nFor a remote daemon, pass --host <host:port> or set PASEO_HOST.",
};
throw error;
}

View File

@@ -1,9 +1,13 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
resolveLocalDaemonState,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
@@ -64,6 +68,17 @@ class FakeDaemonRuntime implements DaemonLaunchRuntime {
}
}
const tempRoots: string[] = [];
async function createPaseoHome(config: unknown): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-"));
tempRoots.push(root);
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
return paseoHome;
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
@@ -78,6 +93,12 @@ describe("local daemon launch supervision", () => {
vi.useRealTimers();
});
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
const runtime = new FakeDaemonRuntime();
@@ -131,4 +152,24 @@ describe("local daemon launch supervision", () => {
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
relay: {
endpoint: "10.0.0.5:51185",
publicEndpoint: "paseo.example.com",
useTls: false,
publicUseTls: true,
},
},
});
const state = resolveLocalDaemonState({ home });
expect(state.relayEndpoint).toBe("paseo.example.com");
expect(state.relayUseTls).toBe(false);
expect(state.relayPublicUseTls).toBe(true);
});
});

View File

@@ -33,6 +33,7 @@ export interface LocalDaemonState {
relayEnabled: boolean;
relayEndpoint: string;
relayUseTls: boolean;
relayPublicUseTls: boolean;
logPath: string;
pidPath: string;
pidInfo: LocalDaemonPidInfo | null;
@@ -409,6 +410,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
relayEnabled: config.relayEnabled ?? true,
relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: config.relayUseTls ?? false,
relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false,
logPath,
pidPath,
pidInfo,

View File

@@ -29,6 +29,7 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
appBaseUrl: config.appBaseUrl,
includeQr: true,
});

View File

@@ -316,7 +316,7 @@ async function resolveDaemonNodeLabel(
function formatRelayStatus(state: ReturnType<typeof resolveLocalDaemonState>): string {
if (!state.relayEnabled) return "disabled";
const scheme = state.relayUseTls ? "wss" : "ws";
const scheme = state.relayPublicUseTls ? "wss" : "ws";
return `${scheme}://${state.relayEndpoint}`;
}

View File

@@ -492,6 +492,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
appBaseUrl: config.appBaseUrl,
includeQr: true,
});

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