Compare commits

...

177 Commits

Author SHA1 Message Date
Mohamed Boudra
63fe5dd0df chore(release): cut 0.1.78 2026-05-18 17:59:57 +07:00
Mohamed Boudra
1b9861846c Improve 0.1.78 changelog and add user-facing examples to release docs 2026-05-18 17:58:49 +07:00
Mohamed Boudra
68bce623c8 Route OpenCode live tests through OpenRouter
Big Pickle's models.dev metadata changed upstream to route via the
Anthropic SDK against the Zen OpenAI-compatible endpoint, which the
endpoint rejects. Swap the live-turn smoke tests to
openrouter/~openai/gpt-mini-latest and gate them on OPENROUTER_API_KEY
so fork PRs skip cleanly when the secret is absent. Also unpin
opencode-ai so CI tracks the latest CLI.
2026-05-18 17:16:57 +07:00
Mohamed Boudra
2072265b98 Stabilize OpenCode provider unit tests 2026-05-18 16:08:46 +07:00
Mohamed Boudra
f7ef0e0b84 Add 0.1.78 changelog 2026-05-18 16:08:21 +07:00
Mohamed Boudra
0419346d6a Pin OpenCode CLI in CI 2026-05-18 16:01:30 +07:00
paseo-ai[bot]
2866a12984 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 08:32:53 +00:00
Mohamed Boudra
120f5d94a9 Fix compact web sheet swipe crashes 2026-05-18 15:29:01 +07:00
Mohamed Boudra
29ce6653fd Improve mobile selectors and sheet inputs
Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
2026-05-18 14:56:17 +07:00
Mohamed Boudra
028839b3cb Add comparison pages under /docs/alternatives
Group the docs sidebar by directory so the new pages cluster
under a heading, add a hamburger menu for the mobile docs nav,
and let the docs main column shrink so wide code blocks and
tables scroll inside instead of pushing the page wide.
2026-05-18 12:59:04 +07:00
Mohamed Boudra
82319f5805 Keep agent panes mounted and focused 2026-05-18 11:24:56 +07:00
Mohamed Boudra
69fc6fe754 Document the positive submission-verification path (eas build:view Logs URL) 2026-05-18 10:16:42 +07:00
Mohamed Boudra
1b2a28be47 Drop eas submit:list references from release/android docs 2026-05-18 10:15:10 +07:00
Mohamed Boudra
665d9cedb5 Drop bogus eas submit:list from release docs and heartbeat checks 2026-05-18 10:14:29 +07:00
Mohamed Boudra
0d0012959a Lock babysit pattern: heartbeat to self, every 15m maxRuns 8 2026-05-18 09:59:00 +07:00
Mohamed Boudra
77b92b58f7 Drop trailing periods from changelog and fix mobile release docs
- Strip end-of-bullet periods from CHANGELOG.md (whole file)
- Tighten 0.1.77 TLS bullet to drop LAN/public explanation
- Release docs: drop release-mobile.yml references (workflow does not exist),
  add EAS mobile-build babysitting section using the EAS CLI, clarify that
  releases are always patch, clarify that "stable" means stable
- Changelog style guide: no trailing periods, one sentence per bullet, lead
  with capability not mechanism
2026-05-18 09:33:46 +07:00
paseo-ai[bot]
d4ebf1815c fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 02:26:22 +00:00
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
Mohamed Boudra
39094078a9 chore(release): cut 0.1.76 2026-05-15 18:07:13 +07:00
Mohamed Boudra
160d861534 Fix agent turn timing persistence 2026-05-15 17:38:28 +07:00
Mohamed Boudra
6cbe4e6a14 Draft changelog for 0.1.76 2026-05-15 15:52:48 +07:00
Mohamed Boudra
0d05ff7efd Resolve inline code file links 2026-05-15 15:50:35 +07:00
Mohamed Boudra
dbe24b609d Fix OpenCode turn timing 2026-05-15 15:22:57 +07:00
Mohamed Boudra
bb6403e426 Stabilize agent turn footer layout 2026-05-15 15:19:00 +07:00
Mohamed Boudra
9bf36cdb56 Handle unborn repos in changes diff 2026-05-15 15:08:41 +07:00
Mohamed Boudra
1f79c4d039 Bump default desktop rollout to 36h 2026-05-15 14:47:42 +07:00
paseo-ai[bot]
5a74970d4a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-15 07:11:28 +00:00
Mohamed Boudra
eed61d338c Tighten markdown list spacing around prose
Lists previously had a static marginVertical that stacked with
paragraph.marginBottom, producing 20px gaps and oversized whitespace
around paragraphs sandwiched between lists.

Spacing is now context-aware via the render rule: list -> list keeps
the normal paragraph gap (back-to-back lists read as continuous
content) while p <-> list transitions get a slightly larger gap (lists
read as their own section). Static margins can't satisfy both because
RN doesn't collapse margins, so the bullet_list / ordered_list rule
inspects the next sibling type and picks marginBottom accordingly.
2026-05-15 14:03:14 +07:00
Mohamed Boudra
2f13c83c0e Sync package-lock.json
Also exclude package-lock.json from the format pre-commit hook glob —
oxfmt internally ignores lockfiles but exits with an error when given
no remaining targets, breaking lockfile-only commits.
2026-05-15 13:35:41 +07:00
Mohamed Boudra
12e24f9620 Show built-in Claude slash command output
Claude CLI's built-in slash commands (/voice, /usage, "Unknown command:
...") run client-side with no model turn — they arrive as a result with
output_tokens: 0 and the user-visible text carried in result.result, but
no preceding assistant_message in the stream. Surface that text as an
assistant_message so the turn doesn't end silently.

Also unify Codex skill-command text blocks to always carry the
"$skill-name" prefix in the text payload, matching the no-args path
which already did.
2026-05-15 13:34:07 +07:00
Mohamed Boudra
37bb168204 Polish agent stream spacing, alignment, and metadata
- Centralize horizontal alignment: StreamItemWrapper / contentWrapper own
  the chat rail (paddingHorizontal: 8). Stream items stop setting their
  own outer horizontal padding. Copy icons get negative margins so the
  visible icon sits flush with the rail edges.

- Unify the inline working indicator and assistant turn footer into a
  shared slot (minHeight: 24, paddingBottom: 24). No layout shift on
  streaming -> idle, and both gain breathing room above the composer
  and the next user bubble.

- Equalize the gap on both sides of an assistant message adjacent to a
  tool call (20px each). Drop assistantMessageStylesheet.containerSpacing,
  which was a redundant marginBottom that only fired in one direction
  and duplicated getGapBetween's responsibility.

- Scale up the chevron via transform (size stays 12, viewBox padding no
  longer dominates), iconBadge dimensions unchanged. Tool icon and
  chevron now read as similar visual weight.

- Shared STREAM_METADATA_FONT_SIZE (13) for user message timestamp,
  assistant footer duration/timestamp, and the live elapsed timer.

- formatDuration: one rule for static and live displays - 0-10s as
  X.Xs, 10-60s as Xs, minutes/hours integers only. Drops the `mode`
  parameter.

- formatMessageTimestamp: explicitly carry the resolved hourCycle so
  the runtime applies an OS-level 24h preference when the platform
  exposes it (e.g. AppleICUForce24HourTime on macOS).
2026-05-15 13:30:57 +07:00
Mohamed Boudra
57db997028 docs: lock down hover pattern, fix user message bubble flicker
The user message bubble had hover on a `Pressable` wrapping the bubble,
with the timestamp and copy button as siblings outside it — moving the
cursor onto them lost hover. Wrapping both in a single Pressable then
flickered, because `TurnCopyButton` is itself a Pressable and nested
Pressables fight over hover state.

Rewrite `UserMessage` to match the workspace row pattern: hover-tracking
on a plain outer `View` with `onPointerEnter`/`onPointerLeave`, nested
Pressables freely inside. Pointer events are non-bubbling DOM events,
so descendants can't disrupt the outer hover.

Rewrite `docs/hover.md` with the workspace row as THE canonical pattern,
not one option among several. Names the three failure modes that come
from skipping it — including "nested Pressables fight over hover" — and
explicitly bounds when `Pressable.onHoverIn`/`onHoverOut` is acceptable
(self-styling only).

Update `constants/platform.ts` so its hover guidance points at the doc
instead of recommending the broken pattern.
2026-05-15 11:25:19 +07:00
Mohamed Boudra
29d46c5d92 test: allow checkout context logger 2026-05-15 10:28:41 +07:00
Mohamed Boudra
a72c7f6c4b Use daemon timestamps for turn timing (#1033) 2026-05-15 11:09:13 +08:00
Mohamed Boudra
1962225647 Document forwarded skills in paseo-advisor
When the user invokes /paseo-advisor with another skill reference
(/unslop, /unslop-risk, $diagnose, etc.), the orchestrator should
instruct the advisor to load and invoke that skill against the current
task before doing anything else.
2026-05-15 09:50:05 +07:00
Mohamed Boudra
2d3c9101f3 Trace git command spawn and close metadata
Thread an optional logger through runGitCommand and the CheckoutContext
helpers so workspace-git-service can emit trace-level traces around each
git invocation. Behavior is unchanged when no logger is supplied.
2026-05-15 09:50:05 +07:00
Mohamed Boudra
f27d39fb63 Normalize default thinking options (#1028) 2026-05-15 00:11:56 +08:00
Mohamed Boudra
5a3fc96b76 simplify contributing.md 2026-05-15 00:09:52 +08:00
Mohamed Boudra
fbeda4510e Fix duplicate project worktree registration (#1024)
* Fix duplicate project worktree registration

* Update archive redirect test for project-aware routes
2026-05-14 20:57:32 +08:00
Mohamed Boudra
7e255a5249 Fix assistant file link resolution (#1025) 2026-05-14 20:55:41 +08:00
Mohamed Boudra
a19b780eca Fix OpenCode retry timeout handling (#1026) 2026-05-14 20:55:19 +08:00
Mohamed Boudra
faa0d93cb8 Fix ahead count when upstream branch is gone (#1023) 2026-05-14 19:10:14 +08:00
Mohamed Boudra
2dd77aae04 Auto-archive worktrees after PR merge (#1004)
Add a daemon setting `autoArchiveAfterMerge` (default off). When on, the
daemon's existing GitHub poll triggers archive once the PR is merged,
but only if the worktree is clean (not dirty, not ahead of origin).
Skips silently otherwise.
2026-05-14 10:08:48 +00:00
Mohamed Boudra
2e8bced707 Auto-attach pasted GitHub PR/issue URLs in the composer (#1006)
* Auto-attach pasted GitHub PR/issue URLs in the composer

Paste a GitHub PR or issue link into any composer and it shows up
as an attachment pill within a debounce tick when the URL matches
the workspace's remote. On the new-workspace screen, an attached PR
also surfaces an inline "Check out PR #N?" hint next to the picker;
one click promotes it to the checkout target through the same
syncPickerPrAttachment flow the dropdown uses.

Unifies the new-workspace picker and composer attachment picker on
one shared GitHub search hook and cache key, so both consumers (and
the auto-attach lookup) share the React Query cache.

* Fix oxfmt formatting in new-workspace-screen import
2026-05-14 17:52:46 +08:00
Mohamed Boudra
8296c797ff Project rename: distinguishable names for duplicate projects (#1003)
* Add project rename so duplicates get distinguishable names

Two checkouts of the same remote collapse into a single project record
(same projectId), which left users unable to tell duplicates apart in
the UI. customName lives alongside the derived displayName as an
override that reconciliation never touches, so renames persist across
git-remote changes. The rename UI lives in project settings.

Closes #987 (rename half).

* Fix COMPAT(projectCustomName) version to v0.1.76

* Update useProjects shape test for new projectCustomName key

* Use sendCorrelatedSessionRequest for renameProject

Match the pattern used by checkoutPrMerge and other newer RPCs
instead of hand-rolling the select callback.

* Use project.rename.{request,response} dotted RPC names

Match the convention from docs/rpc-namespacing.md (added in 75a6f8277).
Schemas, type exports, daemon client, session handler, and tests all
move to the namespaced names. No back-compat shim needed since the RPC
hasn't shipped.
2026-05-14 17:49:24 +08:00
Mohamed Boudra
e1ab779fe0 Fix Shift+Enter in terminal input modes (#999)
* Fix Shift+Enter terminal input modes

* Advertise terminal input capabilities
2026-05-14 17:48:22 +08:00
Mohamed Boudra
eb7fa02ac8 Surface GitHub auto-merge actions (#1001)
* Surface GitHub auto-merge actions

* Namespace GitHub auto-merge RPC

* Update workspace git refresh test expectation

* Avoid redundant GitHub refresh queue
2026-05-14 17:48:05 +08:00
YuKuang
44292b2b69 fix: honor current branch in new workspace flow (#908)
* fix: honor current branch in new workspace flow

* Use OpenCode global event stream (#916)

* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes

* chore: changelog for 0.1.74

* chore(release): cut 0.1.74

* Fix custom Codex provider base URL routing (#915)

* Wire Copilot Allow All mode to ACP permissions (#935)

* Add trace logging and tighten daemon log defaults (#933)

* Add trace logging and tighten daemon log defaults

* Clean up daemon trace logging shape

* Clean up daemon trace logging

* Route provider turn-id checks through shared helper

* Fix supervisor log config test paths on Windows

* Expect resolved supervisor log path on Windows

* nix: declarative config, typed relay options, desktop packaging (#923)

* nix: expose npmDepsHash as a callPackage arg

Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.

Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.

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

* nix: move npmDepsHash default to a sidecar file

Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.

No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.

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

* nix: declarative config via services.paseo.settings

Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.

Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.

`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.

The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.

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

* nix: typed services.paseo.relay options with auto-wired endpoint

Addresses #224 (option surface only).

Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.

Add a typed relay subtree:

- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
  the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
  when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
  the new options answer "how is it configured?".

The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.

No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.

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

* nix: package paseo desktop app for Linux

NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.

Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.

The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.

`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.

No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.

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

* nix: copy full packages/ tree in desktop derivation

The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:

- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
  (the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
  (the CLI launcher script lives under bin/, not dist/)

npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.

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

* nix: route desktop renderer through paseo:// protocol handler

When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.

main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.

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

* ci: track nix/npm-deps.hash in nix-build commit step

The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.

* ci: push nix-build hash commits via paseo-ai[bot] App token

The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* Normalize HEIC image attachments on the client (#934)

* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks

* fix: update lockfile signatures and Nix hash

* ci: skip App token on PRs in nix-build

Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.

* ci: skip CI on nix-build auto hash commits

The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.

* ci: drop unused contents: write override in nix-build

GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.

Addresses #365.

* ci: gate nix-build on same-repo PRs only

scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.

Closes #365.

* ci: split nix workflow into build (anywhere) + update-hash (main only)

build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.

update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.

Rename file to nix.yml since it's no longer just a build job.

* ci: split nix into two workflows

nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.

* Fix iPad sidebar safe area background (#937)

Co-authored-by: zbl <zbl@zbl-M4Pro.local>

* Fix desktop daemon stale PID startup (#913)

* Configure STT language from settings (#941)

* Configure STT language from settings

* Update websocket speech mock for language config

* Handle Windows shell wrappers in Codex command summaries (#931)

* fix(app): respect iPad safe area in settings sidebar (#922)

Co-authored-by: zbl <zbl@zbl-M4Pro.local>

* ci(nix): smoke test daemon boot (#939)

* Fix scheduled agent cleanup (#945)

* Fail Codex resume requests explicitly (#947)

* fix(copilot): preserve legacy autopilot mode alias

* docs(changelog): draft 0.1.75 entry

* chore(release): cut 0.1.75

* fix: update lockfile signatures and Nix hash [skip ci]

* docs(changelog): refine 0.1.75 entry

* fix: preserve worktree action literal types

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: Matan Bendix Shenhav <matan@shenhav.fyi>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Bolun Zhang <zhaggbl@mail.ustc.edu.cn>
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
Co-authored-by: Biao Ma <sigemaict@gmail.com>
Co-authored-by: ezra <ezra.ora.c@gmail.com>
2026-05-13 17:34:57 +00:00
Mohamed Boudra
05c12a9c5c Fix branch-off worktree upstream tracking (#1002) 2026-05-13 17:06:51 +00:00
Mohamed Boudra
5c7a6397da Pin /epic to Paseo agents, not harness subagents 2026-05-13 22:35:29 +07:00
Mohamed Boudra
f1e5640a26 Confirm risky worktree archive (#994) 2026-05-13 23:27:49 +08:00
Mohamed Boudra
512702fe46 Avoid probing extra executable candidates (#996) 2026-05-13 15:14:47 +00:00
Mohamed Boudra
ecc6a269bc Merge branch 'main' of github.com:getpaseo/paseo 2026-05-13 22:14:23 +07:00
Mohamed Boudra
7a0606952d Add Electron renderer remote debugging for profiling 2026-05-13 22:13:47 +07:00
Mohamed Boudra
68fae16740 Recover Pi sessions after Copilot 413 (#995) 2026-05-13 15:00:22 +00:00
Mohamed Boudra
bc329a2d85 Show all PR check counts in hover card (#993) 2026-05-13 14:02:39 +00:00
Mohamed Boudra
e96cd16a0f Deduplicate Codex skill commands (#992) 2026-05-13 13:58:31 +00:00
Mohamed Boudra
316eeee9ca Fix ACP diagnostic model counting (#991) 2026-05-13 13:54:34 +00:00
Mohamed Boudra
c3012ea520 Add Codex context compaction support (#990) 2026-05-13 13:52:21 +00:00
Mohamed Boudra
e2ecae0e72 Preserve Codex assistant message ids (#989) 2026-05-13 13:18:31 +00:00
Mohamed Boudra
49fa72d70d Make provider snapshot refresh explicit (#988) 2026-05-13 20:54:07 +08:00
Bolun Zhang
c6534b3a16 Add MCP provider feature discovery (#910)
Refs #835
2026-05-13 20:12:10 +08:00
Bolun Zhang
15631b815b Add MCP provider feature controls (#909)
Refs #835
2026-05-13 20:05:52 +08:00
paseo-ai[bot]
d30a3a72da fix: update lockfile signatures and Nix hash [skip ci] 2026-05-13 10:52:09 +00:00
Chuan
6ed7dfc42a fix(server): include OpenCode console subscription providers in model list (#917)
* fix(server): include OpenCode console subscription providers in model list

Providers with source "api" (e.g. Pi coding agent via OpenCode Go
subscription) appear in providers.all but not in providers.connected,
which only lists env/config providers. Paseo was filtering exclusively
by connected, so subscription-managed providers were silently dropped
and users saw an empty model list or a misleading "set env vars" error.

Fix: treat source === "api" as accessible in both listModels() and the
context-window lookup helper. Also update the error message to mention
the console login option.

chore: add portless as a devDependency so npm run dev works without
a global install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove portless dependency

* test(server): add unit tests for OpenCode api-source provider inclusion

Covers the source="api" logic added in 08e3c6bf: providers managed by the
OpenCode console/subscription must appear in listModels and the context window
lookup even when absent from the connected list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: drop unrelated package-lock.json churn

The lockfile changes were peer metadata additions from a newer npm
version, not actual dependency changes. Restore to match main.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-13 18:48:00 +08:00
João Sousa Andrade
77dc80b86f Patch compatible transitive advisories (#924)
* Patch compatible transitive advisories

* Fix npm ci lockfile mismatch
2026-05-13 18:47:54 +08:00
Mohamed Boudra
2bd2e8bc46 feat(claude): discover models from settings
Fixes #649, #475, #455, #558
2026-05-13 18:47:50 +08:00
Bolun Zhang
81697f85b2 fix(app): iPad hardware Enter submits the composer
Fixes #810
2026-05-13 17:52:39 +08:00
ezra
72293f7f4f fix(server): fall back PR status queries for fine-grained tokens
Fixes #926
2026-05-13 17:52:13 +08:00
Mohamed Boudra
65d14d4433 Show upstream ACP JSON-RPC errors instead of [object Object]
* Preserve ACP JSON-RPC error details

* Unslop ACP error summarization
2026-05-13 17:43:40 +08:00
Chris Banes
9eb1ddf29d fix(cursor): discover models via Cursor ACP client
Fixes #925
2026-05-13 17:12:53 +08:00
Mohamed Boudra
d18bfe9047 Add local settings to Claude configuration (#977) 2026-05-13 16:34:49 +08:00
Mohamed Boudra
c741185100 Remove personal-skill references from repo skills
Make the repo's paseo skills self-contained — drop mentions of
personal skills (unslop, think-harder, e2e-playwright, fix-build,
rebase) that aren't checked in here.
2026-05-13 15:06:31 +07:00
Mohamed Boudra
ea0bb81100 fix(server): reconcile workspace kind alongside project kind (#975)
The reconciler updated project.kind when a directory became a git repo
but never updated the workspace.kind underneath, leaving the sidebar in
a split-brain state: an expandable git project containing a workspace
with no leading icon (workspaceKind stayed "directory" instead of
flipping to "local_checkout" or "worktree").
2026-05-13 15:58:48 +08:00
Mohamed Boudra
9d7d1e8500 Fix zsh integration runtime file modes (#976) 2026-05-13 15:58:20 +08:00
c4605
16c27d7404 Add Auto Review permission mode for Claude Code (#928)
* Add Auto Review permission mode

* Align Codex Auto Review with auto permissions

* Defer Codex Auto Review to dedicated PR

* fix(app): drop unconditional snapshot refresh on mode selector open

Opening the mode selector was reusing the model selector refresh path, which forced a provider snapshot refresh every time. That made the agent status bar briefly lose its selected provider data and flash empty while the picker opened.

Keep the stale model refresh on the model selector only, and let the mode selector open from the existing snapshot state.

* refactor(claude): rename Claude auto permission mode label to "Auto mode"

Claude Code itself calls this permission setting Auto mode in its CLI/TUI, so match that label in Paseo. The supporting test names and transport eligibility errors now use the same terminology.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-13 13:58:26 +08:00
Mohamed Boudra
b8a3eefd47 feat(codex): implement Auto-review permission mode (#963)
* feat(codex): plumb auto-review approvals reviewer

* feat(codex): add Auto-review mode visuals

* test(codex): cover Auto-review mode plumbing

* test(codex): prove Auto-review approval routing
2026-05-13 13:58:15 +08:00
Mohamed Boudra
d24087c10a Fix relay E2EE reconnect races (#973)
* Fix relay E2EE reconnect races

* fix(relay): use WebSocket protocol ping for daemon control keepalive

The daemon's control socket pinged the relay every 10s with a JSON
`{type:"ping"}` message. Every JSON message wakes a hibernated Cloudflare
Durable Object via `webSocketMessage`, so an idle daemon was billing
relay DO CPU 24/7 just to echo `pong`.

Switch to `socket.ping()` (RFC 6455 protocol ping frames). Cloudflare's
runtime auto-responds at the edge without waking hibernated DOs, so an
idle daemon-to-relay connection now costs zero DO CPU.

Liveness still works: the `ws` library emits a `pong` event on the
daemon when the edge replies, which refreshes `controlLastSeenAt`.
`markControlReady` stays tied to DO-originated messages (sync,
connected, disconnected), so we still detect a broken DO at connect.

The DO keeps its JSON ping handler tagged `COMPAT(relay-json-ping)` so
old daemons (< v0.1.76) continue to work.

* test(relay): assert protocol-ping pongs arrive in e2e idle test

Adds a `relay_control_pong_received` debug log to the daemon's pong
handler, then asserts in the existing idle-relay e2e test that:

  - at least one pong arrived during the 12s idle wait (proving the
    Cloudflare edge auto-responds to RFC 6455 protocol pings)
  - no stale terminations fired (proving the keepalive path is healthy)

If the daemon ever regresses to JSON-level keepalive pings (which wake
the hibernated relay Durable Object and incur CPU billing), this test
will catch it: the `pong` event would never fire because the DO's reply
would arrive via `message`, not as a protocol pong frame.

The test's capturing logger is bumped to `debug` level so the assertion
can read the new debug log; production daemons stay at `info`.

Confirmed locally against wrangler-hosted relay: 2 pongs / 0 stale per
12s window.

* test(relay): catch dual-ping regression via DO-side legacy ping log

The existing idle-test assertion catches "revert to JSON ping only" via
the missing pong event, but misses the dual-ping regression where
someone adds `socket.send(JSON.stringify({type:"ping"}))` alongside
`socket.ping()`. Both paths would work, the pong assertion still
passes, and DO CPU billing silently returns.

Add a console.log in the DO's handleControlKeepalive (which only runs
when a JSON ping reaches the DO), then assert in the idle e2e test
that no such log appeared during the 12s idle window.

Verified the assertion catches the regression: temporarily injected a
`socket.send(JSON.stringify({type:"ping"}))` next to socket.ping(),
test failed with `expected 1 to be 0` and the relay logged
`legacy_json_ping_received`. Removed the injection.

Log noise during the COMPAT window doubles as useful telemetry: it
counts how many old daemons are still pinging, which informs the
removal date for the COMPAT shim.
2026-05-13 13:44:11 +08:00
Bolun Zhang
15cab6014c Refresh MCP worktree cache after create/archive (#911)
* Fix MCP worktree list cache after create/archive

* Fix MCP worktree archive refresh from scoped cwd

---------

Co-authored-by: zbl <zbl@zbl-M4Pro.local>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-12 22:17:17 +08:00
Mohamed Boudra
47d236a299 Add diagnostics for generic ACP providers (#955) 2026-05-12 21:24:03 +08:00
Mohamed Boudra
b9cea49f10 fix(app): auto-set ref picker from a single attached PR (#951)
* fix(app): auto-set ref picker from a single attached PR

Attaching a PR in the new-workspace composer signals "I want this PR's
context", and almost always also means "base the worktree on this PR".
Until now the two paths were independent: the ref picker stayed on the
default branch, so the worktree was branched off main even though the
sidebar chip looked like a PR checkout.

Auto-promote the picker when exactly one PR is attached and the user
hasn't manually picked a ref. Manual picker selections are preserved;
auto-promoted ones are cleared when their backing attachment is removed.

* refactor(app): derive auto-promoted picker selection instead of syncing

The previous version drove `pickerSelection` from a useEffect that
inspected attachments and overwrote state. That meant the shared
`PickerSelection` type had to grow a `source: "manual" | "attachment"`
discriminator just so the effect could tell its own writes apart from
manual picks. Classic effect-as-derived-state.

Split the state instead. `manualPickerSelection` holds explicit picks;
`autoPickerItem` is a useMemo over composer attachments; the effective
`pickerSelection` is `manual ?? auto` via a small precedence helper. No
effect, no `source` flag, and `PickerSelection` stays a screen-local
shape since nothing outside needs it.
2026-05-12 21:08:47 +08:00
Mohamed Boudra
ad88682426 docs(changelog): refine 0.1.75 entry 2026-05-12 17:59:59 +07:00
paseo-ai[bot]
1ddbdf54f1 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-12 10:40:31 +00:00
Mohamed Boudra
51d9563352 chore(release): cut 0.1.75 2026-05-12 17:35:22 +07:00
Mohamed Boudra
045b373168 docs(changelog): draft 0.1.75 entry 2026-05-12 17:34:18 +07:00
Mohamed Boudra
985ad52cce fix(copilot): preserve legacy autopilot mode alias 2026-05-12 17:34:09 +07:00
Mohamed Boudra
d198c68b9e Fail Codex resume requests explicitly (#947) 2026-05-12 17:50:31 +08:00
Mohamed Boudra
751a07124f Fix scheduled agent cleanup (#945) 2026-05-12 17:05:40 +08:00
Mohamed Boudra
defb4f82f7 ci(nix): smoke test daemon boot (#939) 2026-05-12 16:05:15 +08:00
Bolun Zhang
4570e65ce8 fix(app): respect iPad safe area in settings sidebar (#922)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 07:27:49 +00:00
ezra
db0d63dd90 Handle Windows shell wrappers in Codex command summaries (#931) 2026-05-12 15:07:37 +08:00
Mohamed Boudra
1a8fdcd388 Configure STT language from settings (#941)
* Configure STT language from settings

* Update websocket speech mock for language config
2026-05-12 15:03:53 +08:00
Biao Ma
417abed6a5 Fix desktop daemon stale PID startup (#913) 2026-05-12 14:33:18 +08:00
Bolun Zhang
6afdeef84a Fix iPad sidebar safe area background (#937)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 14:24:20 +08:00
Mohamed Boudra
77c82dfdbd ci: split nix into two workflows
nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.
2026-05-12 12:57:38 +07:00
Mohamed Boudra
29277c900d ci: split nix workflow into build (anywhere) + update-hash (main only)
build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.

update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.

Rename file to nix.yml since it's no longer just a build job.
2026-05-12 12:54:22 +07:00
Mohamed Boudra
ed1943058a ci: gate nix-build on same-repo PRs only
scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.

Closes #365.
2026-05-12 12:37:01 +07:00
Mohamed Boudra
e0361ddd22 ci: drop unused contents: write override in nix-build
GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.

Addresses #365.
2026-05-12 12:35:05 +07:00
Mohamed Boudra
ce9474055e ci: skip CI on nix-build auto hash commits
The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.
2026-05-12 12:28:34 +07:00
Mohamed Boudra
8f9b4c8828 ci: skip App token on PRs in nix-build
Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.
2026-05-12 12:26:22 +07:00
paseo-ai[bot]
af4e0de9ab fix: update lockfile signatures and Nix hash 2026-05-12 05:21:32 +00:00
Mohamed Boudra
3acc71b8ad Normalize HEIC image attachments on the client (#934)
* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks
2026-05-12 13:14:25 +08:00
Matan Bendix Shenhav
0759932dad nix: declarative config, typed relay options, desktop packaging (#923)
* nix: expose npmDepsHash as a callPackage arg

Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.

Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.

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

* nix: move npmDepsHash default to a sidecar file

Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.

No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.

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

* nix: declarative config via services.paseo.settings

Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.

Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.

`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.

The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.

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

* nix: typed services.paseo.relay options with auto-wired endpoint

Addresses #224 (option surface only).

Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.

Add a typed relay subtree:

- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
  the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
  when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
  the new options answer "how is it configured?".

The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.

No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.

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

* nix: package paseo desktop app for Linux

NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.

Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.

The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.

`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.

No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.

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

* nix: copy full packages/ tree in desktop derivation

The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:

- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
  (the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
  (the CLI launcher script lives under bin/, not dist/)

npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.

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

* nix: route desktop renderer through paseo:// protocol handler

When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.

main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.

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

* ci: track nix/npm-deps.hash in nix-build commit step

The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.

* ci: push nix-build hash commits via paseo-ai[bot] App token

The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-12 13:11:42 +08:00
Mohamed Boudra
95f45e4e2b Add trace logging and tighten daemon log defaults (#933)
* Add trace logging and tighten daemon log defaults

* Clean up daemon trace logging shape

* Clean up daemon trace logging

* Route provider turn-id checks through shared helper

* Fix supervisor log config test paths on Windows

* Expect resolved supervisor log path on Windows
2026-05-12 12:58:46 +08:00
Mohamed Boudra
a5c2b97e1d Wire Copilot Allow All mode to ACP permissions (#935) 2026-05-12 12:00:20 +08:00
Mohamed Boudra
d32462e9ee Fix custom Codex provider base URL routing (#915) 2026-05-12 11:54:15 +08:00
Mohamed Boudra
33262843a5 chore(release): cut 0.1.74 2026-05-11 18:17:50 +07:00
Mohamed Boudra
1cd02a0e1a chore: changelog for 0.1.74 2026-05-11 18:16:46 +07:00
Mohamed Boudra
40ab9e3f20 Use OpenCode global event stream (#916)
* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes
2026-05-11 09:17:00 +00:00
Mohamed Boudra
4141c76258 chore(release): cut 0.1.73 2026-05-10 22:58:07 +07:00
Mohamed Boudra
7f44323686 chore: changelog for 0.1.73 2026-05-10 22:56:55 +07:00
Mohamed Boudra
152b07b599 fix(server): address OpenCode recovery review findings (#904)
* fix(server): pin OpenCode SDK version

* fix(server): tolerate transient OpenCode recovery poll errors

* fix(server): address OpenCode recovery findings 2 and 4

* fix(server): address OpenCode recovery finding 1

* fix(server): address OpenCode recovery finding 3
2026-05-10 23:40:34 +08:00
Mohamed Boudra
84f36d2e20 fix(server): recover OpenCode turns when 1.14.42+ SSE drops early (#902)
OpenCode 1.14.42+ closes the /event SSE stream cleanly right after
server.connected, breaking the entire turn lifecycle: prompts get
queued and run, but Paseo never sees session.idle / message deltas /
tool calls / questions. Every turn either fails with a generic stream
EOF or hangs waiting for events that never arrive.

Switch the post-EOF recovery to the canonical messages REST endpoint
(via the upgraded SDK) and poll incrementally so tool calls and
clarifying questions surface live during the SSE gap. Bound the wait
with a completion cap and a separate liveness cap so silent rejections
fail fast instead of hanging until the cap. Cap session.abort similarly
so explicit cancels land within seconds.

The recovery path is gated on the for-await loop exiting without a
terminal event, so healthy turns never enter it - if upstream restores
SSE delivery, this code becomes dead and is mechanically removable via
the COMPAT(opencodeEofRecovery) and COMPAT(opencodeSlowAbort) tags.

Also tighten the type of projectSettingsRoute on WorktreeSetupCalloutPolicy
so router.navigate accepts it (was widened to string, broke app
typecheck on main).

Refs: getpaseo/paseo#861, anomalyco/opencode#26697,
anomalyco/opencode#26635
2026-05-10 22:43:25 +08:00
João Sousa Andrade
25d4c5023a Harden file explorer symlink handling (#847) 2026-05-10 13:14:50 +00:00
João Sousa Andrade
3f5acfff31 Restrict desktop external URL schemes (#845) 2026-05-10 21:02:47 +08:00
Mohamed Boudra
b9940e285c Fix Codex sub-agent child tool failure status (#899) 2026-05-10 20:27:23 +08:00
nikuscs
9993c6c6c3 fix(app): avoid bottom sheet text input on web (#898) 2026-05-10 12:18:27 +00:00
Mohamed Boudra
3b7971a463 Fix Windows git command console flashing (#897) 2026-05-10 19:46:25 +08:00
nikuscs
d75d2d857d Fix macOS tab jump shortcut conflict (#859) 2026-05-10 11:34:14 +00:00
Mohamed Boudra
cab42985a5 Fix old relay pairing URL TLS compat (#896) 2026-05-10 19:29:56 +08:00
Mohamed Boudra
ef892bd27d Revert "fix(server): wait for opencode completion after EOF"
This reverts commit 2a84b08129.
2026-05-10 18:28:53 +07:00
Link
3014576c4c fix(server): recover completed opencode turns after SSE EOF (#895)
* fix(server): recover completed opencode turns after SSE EOF (#861)

* fix(server): wait for opencode completion after EOF

The original EOF recovery only checked OpenCode storage once after the SSE stream ended. That missed the real failure mode from #861: OpenCode can drop /event while the turn continues behind the scenes, then persist the assistant completion a little later.

Poll the persisted session for the active turn before failing EOF, while still requiring strong completion evidence and ignoring messages that predate the turn. This preserves failure behavior when there is no persisted completion.

Add behavioral coverage for delayed completion after EOF, partial streamed text plus persisted completion without duplication, stale old completions, and the no-evidence failure path.

---------

Co-authored-by: pluto <plutofog@proton.me>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-10 11:27:14 +00:00
João Sousa Andrade
b8c77bf0e3 Redact MCP debug request logs (#842) 2026-05-10 09:33:29 +00:00
Mohamed Boudra
17073fe8ff refactor(server): exercise codex features through fake app-server (#887) 2026-05-10 09:27:53 +00:00
Mohamed Boudra
6220b47073 refactor(server): extract codex app-server fake (#873) 2026-05-10 17:01:50 +08:00
Mohamed Boudra
bf7f8f686b refactor(cli): inject local daemon launch runtime (#874)
* refactor(cli): inject local daemon launch runtime

* test(app/e2e): target mobile sidebar toggle state
2026-05-10 08:38:21 +00:00
Mohamed Boudra
93cd4734ce refactor(app): extract workspace terminal lifecycle (#881) 2026-05-10 08:24:19 +00:00
Mohamed Boudra
e4acd6cb7a refactor(server): extract task document persistence (#883)
* refactor(server): extract task document persistence

* test(cli): wait for loop list visibility
2026-05-10 08:23:38 +00:00
Mohamed Boudra
ca11fc667b Refactor daemon connection probe tests (#886) 2026-05-10 08:20:16 +00:00
Mohamed Boudra
36e54a097e Refactor worktree create request parsing (#885) 2026-05-10 08:18:00 +00:00
Mohamed Boudra
ecd3137d34 Extract sidebar callout state (#884) 2026-05-10 08:09:17 +00:00
Mohamed Boudra
f881f9ae32 Refactor relay transport socket tests (#882) 2026-05-10 07:53:54 +00:00
Mohamed Boudra
3f6b84899a Extract worktree setup callout policy (#878) 2026-05-10 07:44:44 +00:00
Mohamed Boudra
5e64a1340c Unslop workspace git watch tests (#880) 2026-05-10 07:42:49 +00:00
Mohamed Boudra
2d0ed004e2 Refactor workspace layout id generation (#876) 2026-05-10 07:34:11 +00:00
Mohamed Boudra
183cda2b66 Extract agent archive projection (#877) 2026-05-10 07:32:14 +00:00
Mohamed Boudra
ed2a97fda8 Extract websocket runtime metrics (#875) 2026-05-10 07:28:48 +00:00
可乐小猫
2fed0f09bb Fix infinite recursion in web crypto randomUUID polyfill (#858)
On web (browser/Electron), expo-crypto's randomUUID() and getRandomValues()
just forward to globalThis.crypto.* (see expo-crypto/src/ExpoCrypto.web.ts).
The previous polyfill installed `g.crypto.randomUUID = () => ExpoCrypto.randomUUID()`,
which on web reads back through the same `globalThis.crypto.randomUUID` it
just installed, recursing until the stack overflows. The same trap exists
for getRandomValues, but it rarely triggers because the native version is
almost always present.

Capture a bound reference to the native getRandomValues *before* installing
the polyfill, and generate UUID v4 in JS from 16 random bytes (RFC 4122
version + variant bits). The fallback path stays on ExpoCrypto.getRandomValues
(non-recursive on native), and ExpoCrypto.randomUUID is no longer used.
2026-05-10 07:07:59 +00:00
Mohamed Boudra
478aa4b70e ci: cancel superseded PR runs to free runner capacity
cancel-in-progress is gated to pull_request events so merge_group and
push runs always complete — only redundant CI from rapid PR pushes
gets cancelled.
2026-05-10 14:12:56 +07:00
Mohamed Boudra
fd74abcdca ci: trigger required checks on merge_group events (#879)
Without merge_group on the workflow, PRs entering the merge queue would
fail because no required check ever reports a status against the
merge_group ref.
2026-05-10 15:07:39 +08:00
Mohamed Boudra
444e265275 refactor(server): inject push notification sender (#872) 2026-05-10 06:49:38 +00:00
422 changed files with 34222 additions and 8591 deletions

View File

@@ -5,8 +5,18 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# CI does not use the CUDA execution provider, and the onnxruntime-node
# postinstall download from NuGet is large enough to make npm ci flaky.
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
format:
runs-on: ubuntu-latest
@@ -94,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
@@ -108,6 +118,7 @@ jobs:
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
strategy:

View File

@@ -40,7 +40,7 @@ on:
rollout_hours:
description: "Linear rollout duration in hours. Use 0 for instant rollout."
required: false
default: "24"
default: "36"
type: string
concurrency:
@@ -51,7 +51,7 @@ env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }}
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:

View File

@@ -1,72 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates (main push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push

60
.github/workflows/nix-update-hash.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Nix Update Hash
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-update-hash.yml"
permissions:
contents: read
jobs:
update-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates
run: |
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
git config user.name "paseo-ai[bot]"
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
git add package-lock.json nix/npm-deps.hash
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
git push

99
.github/workflows/nix.yml vendored Normal file
View File

@@ -0,0 +1,99 @@
name: Nix
on:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Smoke Nix daemon
run: |
set -euo pipefail
export PASEO_HOME
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill "$DAEMON_PID"
wait "$DAEMON_PID" || true
fi
rm -rf "$PASEO_HOME"
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Nix daemon exited before becoming reachable."
break
fi
sleep 1
done
echo "::group::daemon.log"
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon status"
./result/bin/paseo daemon status || true
echo "::endgroup::"
exit 1
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop

1
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -21,26 +21,28 @@ This is an npm workspace monorepo:
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| Doc | What's in it |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| Doc | What's in it |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
@@ -86,6 +88,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.

View File

@@ -1,63 +1,32 @@
# Contributing to Paseo
## How this project works
Paseo is an opinionated product maintained by one person.
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
I read every issue and PR myself, and I am selective about what contributions I accept.
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
## Reporting bugs
Core product, design, architecture, and workflow changes are not accepted.
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
Follow these rules if you want your PR to be merged:
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
- **One bug per issue.** Three findings, three issues.
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
## Before you start
Your PR will be closed if you do any of these:
- [README.md](README.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
- [docs/coding-standards.md](docs/coding-standards.md)
- [docs/testing.md](docs/testing.md)
- [CLAUDE.md](CLAUDE.md)
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Make product, design, or architecture changes without prior discussion
- Submit no evidence of testing
- Skip the linked issue
- Clearly fully AI-generated PR
## What is most helpful
## AI assistance
- bug fixes (especially Windows and Linux)
- regression fixes
- doc improvements
- packaging and platform fixes
- focused UX improvements that fit the product direction
- tests that lock down important behavior
## Development setup
```bash
npm run dev # daemon + expo
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
```
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
## Pull requests
- One focused change per PR. Split unrelated cleanups out.
- Reference the issue you're fixing, unless it's a small objective bug.
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
- `npm run typecheck` and `npm run lint` must pass.
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
## Forks are fine
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.

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
@@ -48,24 +48,28 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Recent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
# which has a Submissions section showing the auto-submit to the Play Store).
npx eas build:view <build-id>
```
The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.
See [docs/release.md](release.md) for the full mobile-build babysitting flow.

View File

@@ -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.
@@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
- `agent_update` — Agent state changed (status, title, labels)
@@ -205,6 +207,7 @@ initializing → idle ⇄ running
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)

View File

@@ -59,6 +59,30 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
---
## Z.AI (Zhipu) coding plan
@@ -342,6 +366,14 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
@@ -504,6 +536,12 @@ Each entry in the `models` array:
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.

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: {
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
providers: Record<providerId, ProviderOverride>
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
@@ -362,6 +362,11 @@ Array of project records.
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
emptied duplicate.
---
## 7. Workspace Registry

View File

@@ -216,7 +216,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.

View File

@@ -37,9 +37,22 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In any worktree-style or portless setup, never assume default ports.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts

138
docs/hover.md Normal file
View File

@@ -0,0 +1,138 @@
# Hover
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
## The pattern
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
```tsx
//
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
// │
<View
style={styles.workspaceRowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable // ┐ Separate inner Pressable.
onPress={handlePress} // │ Handles press only.
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
onPressOut={...} // ┘
style={workspaceRowStyle}
>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}></View>
<WorkspaceRowRightGroup isHovered={isHovered} />
{/* └─ Reveals content based on hover state. */}
</View>
</Pressable>
</View>
```
Five things make this work. Every one of them matters.
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
That's the whole pattern. Internalize it.
## When you skip the pattern, here is what breaks
### Failure mode 1 — Nested Pressables fight over hover state
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
### Failure mode 2 — The hovered state changes the trigger's geometry
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
Common variants:
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
- Hover state swaps the trigger for a different element type, remounting it.
Fixes, in preferred order:
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
### Failure mode 3 — Revealed content lives outside the hover trigger
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
Wrong:
```tsx
<View>
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
<Bubble />
</View>
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
</View>
```
Right:
```tsx
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
<Bubble />
<TrailingRow /> {/* INSIDE — child */}
</View>
```
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
```tsx
const showControls = isHovered || isNative || isCompact;
```
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
## What about `Pressable.onHoverIn` / `onHoverOut`?
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
## Real gaps with floating panels
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
## Pre-PR checklist
Before opening a PR that touches hover:
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.

View File

@@ -0,0 +1,49 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`

View File

@@ -16,6 +16,18 @@ 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
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
---
## ACP Provider Checklist

View File

@@ -21,11 +21,11 @@ Before running any stable patch release command:
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
## Manual step-by-step
@@ -61,7 +61,7 @@ Use the beta path when you need to:
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
@@ -74,16 +74,16 @@ Updater clients only discover a release through those `.yml` manifests, so there
### Default behavior
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash
# 1. Cut and publish (default 24h ramp from tag push).
# 1. Cut and publish (default 36h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
@@ -92,7 +92,7 @@ gh workflow run desktop-rollout.yml \
-f rollout_hours=0
```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
@@ -132,7 +132,7 @@ gh workflow run desktop-release.yml \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
@@ -147,6 +147,63 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
## Mobile builds (EAS)
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
```bash
cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Filter by platform.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
```
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW``IN_QUEUE``IN_PROGRESS``FINISHED` (or `ERRORED`/`CANCELED`).
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
### Babysitting mobile after a release
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
Pattern:
```jsonc
// mcp__paseo__create_schedule arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"every": "15m",
"maxRuns": 8, // covers ~2h of build + store-submission window
"target": "self", // heartbeat, NOT "new-agent"
"cwd": "/path/to/paseo",
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build that errors at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as everything is green so the remaining runs do not generate noise.
## Release notes on GitHub
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
@@ -230,7 +287,20 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
| Wrong (implementation-facing) | Right (user-facing) |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
| Added debouncing to the search input | Search results no longer lag behind typing |
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
@@ -241,9 +311,12 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
@@ -319,4 +392,5 @@ In other words, betas are checkpoints along the way; the changelog entry remains
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store

84
docs/rpc-namespacing.md Normal file
View File

@@ -0,0 +1,84 @@
# RPC Namespacing
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Operation: `set_auto_merge`
- Direction: `request` or `response`
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
## Request/Response Pairs
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
## Message Shape
Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
requestId: "req_123"
}
```
Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
success: true,
error: null,
requestId: "req_123"
}
}
```
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
Provider-specific behavior belongs under the provider segment:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
## Compatibility
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
```ts
checkout_pr_merge_request;
checkout_pr_merge_response;
```
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
- Add the new names first.
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
- Keep old names accepted until the compatibility window expires.
- Mark shims with `COMPAT(...)` and a removal date.

View File

@@ -26,11 +26,18 @@
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
}
);
nixosModules.default = self.nixosModules.paseo;

View File

@@ -3,6 +3,8 @@ pre-commit:
jobs:
- name: format
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
exclude:
- "**/package-lock.json"
run: npm run format:check:files -- {staged_files}
- name: lint
glob: "*.{js,jsx,ts,tsx}"

159
nix/desktop-package.nix Normal file
View File

@@ -0,0 +1,159 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
# without this, the desktop drv produces a separately-named store path
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
paseo,
}:
buildNpmPackage rec {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter =
path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store"
&& baseName != "release";
};
nodejs = nodejs_22;
inherit (paseo) npmDeps;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
EXPO_NO_TELEMETRY = "1";
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
};
buildPhase = ''
runHook preBuild
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# App workspace deps not covered by build:daemon
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only NOT electron-builder)
npm run build:main --workspace=@getpaseo/desktop
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
copyDesktopItems
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
})
];
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
};
}

View File

@@ -81,7 +81,48 @@ in
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
description = ''
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
};
@@ -94,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.
'';
@@ -111,9 +153,50 @@ in
'';
description = "Extra environment variables for the Paseo daemon.";
};
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
};
config = lib.mkIf cfg.enable {
config = lib.mkIf cfg.enable (
let
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
in
{
assertions = [
{
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
message = ''
services.paseo.relay.host must be set when relay.mode = "remote".
'';
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
@@ -131,24 +214,45 @@ in
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = {
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;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // cfg.environment;
serviceConfig = {
@@ -172,5 +276,6 @@ in
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
);
}

1
nix/npm-deps.hash Normal file
View File

@@ -0,0 +1 @@
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=

View File

@@ -7,6 +7,15 @@
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
@@ -40,9 +49,9 @@ buildNpmPackage rec {
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -79,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 \

968
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.72",
"version": "0.1.78",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -93,6 +93,7 @@
"devDependencies": {
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"@vercel/nft": "^1.5.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"get-port-cli": "^3.0.0",

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

@@ -1,12 +1,21 @@
name: Resubmit iOS for App Store review
# Standalone re-trigger for the App Store review submission step. The iOS
# binary is already uploaded to TestFlight via the main release-mobile flow;
# this workflow just runs the fastlane submit_review lane against the latest
# TestFlight build, without rebuilding or re-uploading.
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push
# build; this workflow just runs the fastlane submit_review lane against the
# latest TestFlight build, without rebuilding or re-uploading.
on:
workflow_dispatch: {}
workflow_dispatch:
inputs:
app_version:
type: string
required: false
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
app_build_version:
type: string
required: false
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
jobs:
submit_ios_for_review:
@@ -18,4 +27,7 @@ jobs:
- name: Install fastlane
run: bundle install
- name: Submit for review
run: bundle exec fastlane ios submit_review
run: |
export APP_VERSION="${{ inputs.app_version }}"
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
bundle exec fastlane ios submit_review

View File

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

View File

@@ -6,6 +6,7 @@ import {
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import { clickNewChat } from "./helpers/launcher";
import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
@@ -42,4 +43,23 @@ test.describe("Agent stream UI", () => {
await repo.cleanup();
}
});
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
await workspace.navigateTo();
await clickNewChat(page);
await page.getByText("Model defaults are still loading").waitFor({
state: "hidden",
timeout: 30_000,
});
const prompt = "Stream briefly for first app-created turn timer test.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await page.getByText(prompt, { exact: true }).first().waitFor({ state: "visible" });
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
});
});

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

@@ -21,12 +21,13 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click();
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.getByTestId("menu-button").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

@@ -0,0 +1,64 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
} from "./helpers/archive-tab";
import { expectComposerVisible } from "./helpers/composer";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
test.describe("Workspace pane mounting", () => {
test("opening the first split pane keeps the existing agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("pane-remount-");
let agentId: string | null = null;
try {
const agent = await createIdleAgent(client, {
cwd: repo.path,
title: `pane-remount-${Date.now()}`,
});
agentId = agent.id;
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
const originalComposer = await page
.getByTestId("message-input-root")
.filter({ visible: true })
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
await page.getByRole("button", { name: "Split pane right" }).first().click();
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
2,
{ timeout: 30_000 },
);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
} finally {
if (agentId) {
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});

View File

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

@@ -0,0 +1,7 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["PaseoHardwareKeyboardModule"],
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
}
}

View File

@@ -0,0 +1,24 @@
require 'json'
Pod::Spec.new do |s|
s.name = 'PaseoHardwareKeyboard'
s.version = '0.1.0'
s.summary = 'Hardware keyboard shortcuts for Paseo'
s.description = 'Hardware keyboard shortcuts for Paseo'
s.license = 'AGPL-3.0-or-later'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '13.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
end

View File

@@ -0,0 +1,99 @@
import ExpoModulesCore
import UIKit
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
@objc
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
public override func createRootViewController() -> UIViewController? {
return PaseoHardwareKeyboardRootViewController()
}
}
public class PaseoHardwareKeyboardModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoHardwareKeyboard")
Events(hardwareSubmitEventName)
OnCreate {
activeModule = self
}
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
DispatchQueue.main.async {
isHardwareSubmitEnabled = enabled
}
}
OnDestroy {
if activeModule === self {
activeModule = nil
}
isHardwareSubmitEnabled = false
}
}
fileprivate func emitHardwareKeyboardSubmit() {
sendEvent(hardwareSubmitEventName, [:])
}
}
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
override var keyCommands: [UIKeyCommand]? {
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
return super.keyCommands
}
let command = UIKeyCommand(
input: "\r",
modifierFlags: [],
action: #selector(handleHardwareKeyboardSubmit(_:))
)
if #available(iOS 15.0, *) {
command.wantsPriorityOverSystemBehavior = true
}
return (super.keyCommands ?? []) + [command]
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
return
}
activeModule?.emitHardwareKeyboardSubmit()
}
private func canSubmitCurrentTextInput() -> Bool {
guard let responder = UIResponder.paseoCurrentFirstResponder else {
return false
}
guard let textInput = responder as? UITextInput else {
return false
}
return textInput.markedTextRange == nil
}
}
private extension UIResponder {
private static weak var currentFirstResponder: UIResponder?
static var paseoCurrentFirstResponder: UIResponder? {
currentFirstResponder = nil
UIApplication.shared.sendAction(
#selector(captureCurrentFirstResponder(_:)),
to: nil,
from: nil,
for: nil
)
return currentFirstResponder
}
@objc
private func captureCurrentFirstResponder(_ sender: Any?) {
UIResponder.currentFirstResponder = self
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "paseo-hardware-keyboard",
"version": "0.1.0",
"private": true
}

View File

@@ -1,16 +1,16 @@
{
"name": "@getpaseo/app",
"version": "0.1.72",
"version": "0.1.78",
"private": true,
"main": "index.ts",
"scripts": {
"start": "APP_VARIANT=development expo start",
"start": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "expo run:ios",
@@ -33,7 +33,7 @@
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
@@ -63,6 +63,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",

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

@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() {
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
const params = useLocalSearchParams<{
serverId?: string;
dir?: string;
name?: string;
projectId?: string;
}>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
if (!sourceDirectory) {
return null;
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
/>
);
}

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

@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import {
classifyAssistantFileLink,
normalizeInlinePathTarget,
parseAssistantFileLink,
parseFileProtocolUrl,
parseInlinePathToken,
} from "./inline-path";
} from "./parse";
describe("parseInlinePathToken", () => {
it("returns null for plain paths without a line number", () => {
@@ -30,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();
@@ -46,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",
@@ -70,7 +107,170 @@ describe("parseFileProtocolUrl", () => {
});
});
describe("classifyAssistantFileLink", () => {
it("keeps explicit external URLs out of file parsing", () => {
expect(
classifyAssistantFileLink("http://dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "external",
raw: "http://dumm.md",
});
expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
kind: "external",
raw: "mailto:test@example.com",
});
});
it("classifies bare workspace candidates separately from direct relative files", () => {
expect(
classifyAssistantFileLink("dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "ambiguousFileCandidate",
target: {
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
});
expect(
classifyAssistantFileLink("message-renderer.tsx", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "ambiguousFileCandidate",
target: {
raw: "message-renderer.tsx",
path: "/Users/test/project/message-renderer.tsx",
lineStart: undefined,
lineEnd: undefined,
},
});
expect(
classifyAssistantFileLink("src/components/message.tsx#L33", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "directFile",
target: {
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
});
});
it("does not classify normal bare domains as file candidates", () => {
expect(
classifyAssistantFileLink("google.com", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
classifyAssistantFileLink("example.com", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
classifyAssistantFileLink("openai.com/path", {
workspaceRoot: "/Users/test/project",
}),
).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", () => {
it("resolves bare markdown filenames against the active workspace", () => {
expect(
parseAssistantFileLink("dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("resolves bare source filenames with line suffixes against the active workspace", () => {
expect(
parseAssistantFileLink("file.ts:12", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("rejects bare domains and domain-like paths", () => {
expect(
parseAssistantFileLink("google.com", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
parseAssistantFileLink("google.com:80", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
parseAssistantFileLink("openai.com/path", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
});
it("resolves relative paths against the active workspace", () => {
expect(
parseAssistantFileLink("src/components/message.tsx#L33", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
});
});
it("parses absolute POSIX hrefs inside the active workspace", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
@@ -146,6 +346,11 @@ describe("parseAssistantFileLink", () => {
it("rejects external URLs", () => {
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
expect(
parseAssistantFileLink("http://dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
});
it("rejects invalid line fragments", () => {

View File

@@ -1,4 +1,4 @@
import { isAbsolutePath } from "./path";
import { isAbsolutePath } from "@/utils/path";
export interface InlinePathTarget {
raw: string;
@@ -8,12 +8,86 @@ 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",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"cts",
"cxx",
"env",
"fish",
"go",
"gql",
"gradle",
"graphql",
"h",
"hpp",
"htm",
"html",
"ini",
"java",
"js",
"json",
"jsonc",
"jsx",
"kt",
"kts",
"less",
"lock",
"lua",
"md",
"mdx",
"mjs",
"mts",
"php",
"proto",
"py",
"rb",
"rs",
"sass",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"txt",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
}
export type AssistantFileLinkClassification =
| {
kind: "external";
raw: string;
}
| {
kind: "directFile";
target: InlinePathTarget;
}
| {
kind: "ambiguousFileCandidate";
target: InlinePathTarget;
};
export interface NormalizedInlinePathTarget {
directory: string;
file?: string;
@@ -71,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;
}
@@ -172,6 +249,45 @@ function parseAssistantInlinePathLink(
};
}
export function classifyAssistantFileLink(
value: string,
options: AssistantHrefParseOptions = {},
): AssistantFileLinkClassification | null {
const raw = value ?? "";
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (isExternalHref(trimmed)) {
return {
kind: "external",
raw,
};
}
if (/\s/.test(trimmed)) {
return null;
}
const target = parseAssistantFileLink(trimmed, options);
if (!target) {
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
return {
kind: "ambiguousFileCandidate",
target,
};
}
return {
kind: "directFile",
target,
};
}
export function parseAssistantFileLink(
value: string,
options: AssistantHrefParseOptions = {},
@@ -186,7 +302,7 @@ export function parseAssistantFileLink(
return null;
}
if (trimmed.includes("://")) {
if (isExternalHref(trimmed)) {
return null;
}
@@ -216,6 +332,13 @@ export function parseAssistantFileLink(
};
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
});
if (relativeTarget) {
return relativeTarget;
}
if (!isAbsolutePath(trimmed)) {
return null;
}
@@ -248,6 +371,96 @@ export function parseAssistantFileLink(
};
}
export function isFileLookingAssistantToken(value: string): boolean {
const normalized = normalizePathToken(value);
if (
!normalized ||
/\s/.test(normalized) ||
normalized.includes("?") ||
normalized.includes("://")
) {
return false;
}
const path = getHeuristicLocalPath(normalized);
if (!path) {
return false;
}
return isPlausibleAssistantLocalPath(path);
}
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const workspaceRoot = normalizePathInput(options.workspaceRoot);
if (!workspaceRoot) {
return null;
}
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
const normalizedPath = resolveRelativePathUnderRoot(parsed.path, workspaceRoot);
if (!normalizedPath) {
return null;
}
return {
raw: value,
path: normalizedPath,
...parsed.lines,
};
}
function parseLocalPathParts(
value: string,
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
}
const hashIndex = normalized.indexOf("#");
const beforeHash = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
const hash = hashIndex >= 0 ? normalized.slice(hashIndex) : "";
const fragmentLines = parseLineFragment(hash);
if (!fragmentLines) {
return null;
}
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
return null;
}
return {
path: inlinePathTarget.path,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
},
};
}
if (!beforeHash || beforeHash.includes(":")) {
return null;
}
if (!isPlausibleAssistantLocalPath(beforeHash)) {
return null;
}
return {
path: beforeHash,
lines: fragmentLines,
};
}
export function normalizeInlinePathTarget(
rawPath: string,
cwd?: string,
@@ -308,6 +521,139 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
}
function isExternalHref(value: string): boolean {
if (value.includes("://")) {
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
}
const inlinePathTarget = parseInlinePathToken(value);
if (inlinePathTarget) {
return false;
}
return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value);
}
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
workspaceRoot?: string,
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
return !parsed.path.includes("/");
}
function getHeuristicLocalPath(value: string): string | null {
const hashIndex = value.indexOf("#");
const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
if (!parseLineFragment(hash)) {
return null;
}
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
return inlinePathTarget.path;
}
if (!beforeHash || beforeHash.includes(":")) {
return null;
}
return beforeHash;
}
function isPlausibleAssistantLocalPath(pathValue: string): boolean {
const normalized = normalizePathToken(pathValue);
if (!normalized) {
return false;
}
if (isAbsolutePath(normalized)) {
return true;
}
const explicitRelative =
normalized.startsWith("./") || normalized.startsWith("../") || normalized.startsWith("~/");
if (explicitRelative) {
return true;
}
const segments = normalized.split("/").filter(Boolean);
const firstSegment = segments[0];
if (!firstSegment) {
return false;
}
if (segments.length > 1) {
const lastSegment = segments[segments.length - 1];
return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
}
return isPlausibleAssistantFileName(firstSegment);
}
function isPlausibleAssistantFileName(fileName: string | undefined): boolean {
if (!fileName) {
return false;
}
if (fileName.startsWith(".") && fileName.length > 1) {
return true;
}
const lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
return false;
}
const extension = fileName.slice(lastDot + 1).toLowerCase();
return ASSISTANT_FILE_EXTENSIONS.has(extension);
}
function isDomainLikePathSegment(segment: string): boolean {
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
}
function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
const normalizedPath = normalizePathToken(pathValue);
if (!normalizedPath || isAbsolutePath(normalizedPath)) {
return null;
}
const root = workspaceRoot.replace(/\/+$/, "") || "/";
const pathSegments = normalizedPath.split("/");
const resolvedSegments: string[] = [];
for (const segment of pathSegments) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (resolvedSegments.length === 0) {
return null;
}
resolvedSegments.pop();
continue;
}
resolvedSegments.push(segment);
}
if (resolvedSegments.length === 0) {
return root;
}
return root === "/" ? `/${resolvedSegments.join("/")}` : `${root}/${resolvedSegments.join("/")}`;
}
function normalizeFileUrlPath(pathname: string): string | null {
if (!pathname) {
return null;

View File

@@ -0,0 +1,604 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistantFileLinkResolver,
getAssistantFileLinkToken,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
} from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
};
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
interface DirectorySearch {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
class FakeWorkspaceFiles {
readonly searches: DirectorySearch[] = [];
readonly openedFiles: OpenedFile[] = [];
readonly unresolvedTokens: string[] = [];
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
});
}
private getDirectorySuggestions = async (
search: DirectorySearch,
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
private openWorkspaceFile = (
target: InlinePathTarget,
disposition: OpenFileDisposition,
): void => {
this.openedFiles.push({ target, disposition });
};
private onUnresolvedFileCandidate = (token: string): void => {
this.unresolvedTokens.push(token);
};
}
describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
);
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await Promise.all([
resolver.prefetch({ context: CONTEXT, source }),
resolver.prefetch({ context: CONTEXT, source }),
]);
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(result.opened).toBe(true);
});
it("click consumes an in-flight hover resolution", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const suggestions = vi.fn(() => deferred.promise);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
const prefetch = resolver.prefetch({ context: CONTEXT, source });
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("retries a click after hover prefetch fails to query suggestions", async () => {
const suggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(openResult.opened).toBe(true);
});
it("does not cache unresolved candidates", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(first).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
expect(second.opened).toBe(true);
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
});
it("keys cache entries by server, workspace, and token", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await resolver.open({ context: CONTEXT, source, disposition: "main" });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
disposition: "main",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
{
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
});
it("does not apply stale async results after the active context changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
let isCurrent = true;
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(() => deferred.promise),
openWorkspaceFile,
openExternalUrl: vi.fn(),
isCurrentContext: () => isCurrent,
});
const opened = resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
isCurrent = false;
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
const result = await opened;
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(result.opened).toBe(false);
expect(result.kind).toBe("file");
});
it("opens direct workspace file links without querying suggestions", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("preserves direct workspace file line ranges", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx:33-40" },
disposition: "main",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
"main",
);
});
it("opens basename line refs when the daemon returns that exact filename", async () => {
const workspaceFiles = new FakeWorkspaceFiles({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "file.ts:12",
text: "file.ts:12",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([
{
target: {
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
lineEnd: undefined,
},
disposition: "main",
},
]);
expect(result.opened).toBe(true);
});
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
const workspaceFiles = new FakeWorkspaceFiles({});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "src/file.ts",
text: "src/file.ts",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "src/file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([]);
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "src/file.ts",
opened: false,
});
});
it("passes side open disposition to workspace file links", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "side",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"side",
);
});
it("keeps explicit external URLs external", async () => {
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md" },
disposition: "main",
});
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
expect(result).toEqual({
kind: "external",
url: "http://dumm.md",
opened: true,
});
});
it("keeps auto-linkified normal domains external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
expect(result).toEqual({
kind: "external",
url: "http://google.com",
opened: true,
});
});
it("keeps auto-linkified normal domain paths external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
expect(result).toEqual({
kind: "external",
url: "http://openai.com/path",
opened: true,
});
});
it("does not open unresolved linkified markdown filenames in the browser", async () => {
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const prefetchResult = await resolver.prefetch({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
disposition: "main",
});
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(openExternalUrl).not.toHaveBeenCalled();
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("keeps failed ambiguous resolution out of the browser", async () => {
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => {
throw new Error("daemon unavailable");
}),
openWorkspaceFile: vi.fn(),
openExternalUrl,
onUnresolvedFileCandidate,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
getAssistantFileLinkToken({
href: "http://dumm.md",
text: "dumm.md",
markup: "linkify",
sourceInfo: "auto",
}),
).toBe("dumm.md");
expect(
getAssistantFileLinkToken({
href: "http://google.com",
text: "google.com",
markup: "linkify",
sourceInfo: "auto",
}),
).toBe("http://google.com");
expect(
getAssistantFileLinkToken({
href: "http://dumm.md",
text: "dumm.md",
markup: "",
sourceInfo: "",
}),
).toBe("http://dumm.md");
expect(
getAssistantFileLinkToken({
href: "workspace-git-service.ts:1553",
text: "workspace-git-service.ts:1553",
sourceType: "inline-code",
}),
).toBe("workspace-git-service.ts:1553");
});
});

View File

@@ -0,0 +1,307 @@
import {
classifyAssistantFileLink,
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource {
href: string;
text?: string;
markup?: string;
sourceInfo?: string;
sourceType?: "inline-code";
}
export interface DirectorySuggestionEntry {
path: string;
kind: "file" | "directory";
}
export interface DirectorySuggestionResult {
entries: DirectorySuggestionEntry[];
error: string | null;
}
export interface AssistantFileLinkResolverDependencies {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkPrefetchInput {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
disposition: OpenFileDisposition;
}
export type ResolvedAssistantFileLink =
| {
kind: "external";
url: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
token: string;
}
| {
kind: "ignored";
};
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
opened: boolean;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
token: string;
classification: AssistantFileLinkClassification;
}
export function createAssistantFileLinkResolver(
dependencies: AssistantFileLinkResolverDependencies,
): AssistantFileLinkResolver {
const cache = new Map<string, CachedAssistantFileLink>();
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
async function resolve(
input: AssistantFileLinkPrefetchInput,
): Promise<ResolvedAssistantFileLink> {
const parsed = parseInteraction(input);
if (!parsed) {
return { kind: "ignored" };
}
if (parsed.classification.kind === "external") {
return { kind: "external", url: parsed.classification.raw };
}
if (
parsed.classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context: input.context,
source: input.source,
token: parsed.token,
target: parsed.classification.target,
})
) {
return { kind: "file", target: parsed.classification.target };
}
const key = getResolutionKey(input.context, parsed.token);
const cached = cache.get(key);
if (cached) {
return cached;
}
const active = inFlight.get(key);
if (active) {
return active;
}
const request = resolveAmbiguousCandidate({
context: input.context,
token: parsed.token,
target: parsed.classification.target,
getDirectorySuggestions: dependencies.getDirectorySuggestions,
})
.then((result) => {
if (result.kind === "file") {
cache.set(key, result);
}
inFlight.delete(key);
return result;
})
.catch((): CachedAssistantFileLink => {
inFlight.delete(key);
return { kind: "unresolvedFileCandidate", token: parsed.token };
});
inFlight.set(key, request);
return request;
}
return {
prefetch(input) {
return resolve(input);
},
async open(input) {
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
if (resolved.kind === "file") {
dependencies.openWorkspaceFile(resolved.target, input.disposition);
return { ...resolved, opened: true };
}
if (resolved.kind === "external") {
await dependencies.openExternalUrl(resolved.url);
return { ...resolved, opened: true };
}
if (resolved.kind === "unresolvedFileCandidate") {
dependencies.onUnresolvedFileCandidate?.(resolved.token);
}
return { ...resolved, opened: false };
},
};
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return text;
}
}
return source.href;
}
function parseInteraction(
input: AssistantFileLinkPrefetchInput,
): ParsedAssistantFileLinkInteraction | null {
const token = getAssistantFileLinkToken(input.source).trim();
if (!token) {
return null;
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: input.context.workspaceRoot,
});
if (!classification) {
return null;
}
return { token, classification };
}
async function resolveAmbiguousCandidate(input: {
context: AssistantFileLinkContext;
token: string;
target: InlinePathTarget;
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
}): Promise<CachedAssistantFileLink> {
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
const suggestions = await input.getDirectorySuggestions({
query,
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
return {
kind: "file",
target: {
...input.target,
path: joinWorkspacePath(workspaceRoot, match.path),
},
};
}
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
const lastSlash = normalizedPath.lastIndexOf("/");
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
}
function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
token: string;
target: InlinePathTarget;
}): boolean {
if (input.source.sourceType !== "inline-code") {
return false;
}
if (isAbsoluteInlineCodeToken(input.token)) {
return false;
}
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return false;
}
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = input.target.path.replace(/\\/g, "/");
return normalizedPath.startsWith(`${normalizedRoot}/`);
}
function isAbsoluteInlineCodeToken(token: string): boolean {
return (
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
}
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
return source.markup === "linkify" || source.sourceInfo === "auto";
}
function canApplyResult(
context: AssistantFileLinkContext,
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
): boolean {
return isCurrentContext ? isCurrentContext(context) : true;
}
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return root ? `${root}/${child}` : child;
}

View File

@@ -0,0 +1,90 @@
import { useMemo, useRef } from "react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ToastApi } from "@/components/toast-host";
import {
createAssistantFileLinkResolver,
type AssistantFileLinkContext,
type AssistantFileLinkOpenInput,
type AssistantFileLinkPrefetchInput,
} from "./resolver";
import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {
client?: DaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkActions {
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
}
export function useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
const context: AssistantFileLinkContext = useMemo(
() => ({
serverId,
workspaceRoot,
}),
[serverId, workspaceRoot],
);
const latestContextRef = useRef(context);
latestContextRef.current = context;
const resolver = useMemo(
() =>
createAssistantFileLinkResolver({
async getDirectorySuggestions(input) {
if (!client) {
return { entries: [], error: null };
}
const result = await client.getDirectorySuggestions(input);
return {
entries: result.entries,
error: result.error,
};
},
openWorkspaceFile(target, disposition) {
onOpenWorkspaceFile?.(target, disposition);
},
openExternalUrl,
onUnresolvedFileCandidate(token) {
toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
},
isCurrentContext(candidate) {
const current = latestContextRef.current;
return (
current.serverId === candidate.serverId &&
current.workspaceRoot === candidate.workspaceRoot
);
},
}),
[client, onOpenWorkspaceFile, toast],
);
return useMemo(
() => ({
prefetch(input) {
void resolver.prefetch({ ...input, context });
},
open(input) {
void resolver.open({ ...input, context });
},
}),
[context, resolver],
);
}

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

@@ -12,14 +12,45 @@ import {
BottomSheetTextInput,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
// Horizontal indent token shared by the sheet header (title, back arrow,
// leading icon, search input icon) and any row primitive rendered inside the
// sheet body. Rows whose leading icon should line up with the header must
// match this padding.
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
export interface SheetHeaderSearch {
value: string;
onChange: (value: string) => void;
initialValue?: string;
resetKey?: string | number;
placeholder?: string;
autoFocus?: boolean;
testID?: string;
}
export interface SheetHeaderBack {
onPress: () => void;
label?: string;
accessibilityLabel?: string;
}
export interface SheetHeader {
title: string;
subtitle?: ReactNode;
back?: SheetHeaderBack;
leading?: ReactNode;
actions?: ReactNode;
search?: SheetHeaderSearch;
}
type EscHandler = () => void;
const escStack: EscHandler[] = [];
@@ -72,23 +103,30 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.surface2,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
headerContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
headerRow: {
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerBackButton: {
borderRadius: theme.borderRadius.lg,
},
headerLeadingSlot: {
alignItems: "center",
justifyContent: "center",
},
headerTitleGroup: {
flex: 1,
gap: theme.spacing[2],
gap: theme.spacing[1],
minWidth: 0,
},
title: {
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
@@ -96,52 +134,78 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
searchRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
paddingBottom: theme.spacing[3],
},
// Inline variants for InlineHeaderView inside the desktop Combobox popover.
// Horizontal padding matches the model picker's row indent: the picker uses
// children mode (desktopChildrenScrollContent, no scroll padding), so the
// row content starts at item.paddingHorizontal = spacing[3].
inlineHeaderRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inlineSearchRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
inlineTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
desktopScroll: {
flexShrink: 1,
minHeight: 0,
},
desktopContent: {
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
flexGrow: 1,
},
bottomSheetHeader: {
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[3],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
bottomSheetContent: {
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
flex: 1,
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
padding: theme.spacing[6],
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
@@ -158,14 +222,186 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <View style={combinedStyle} />;
}
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
};
// React Native controlled TextInput can replay stale JS values during fast input
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
// can seed it once with initialValue and remount with resetKey for real resets.
// See https://github.com/facebook/react-native/issues/44157
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
const textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
};
if (isMobile && isNative) {
return (
<BottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
export function SheetHeaderView({
header,
onClose,
showCloseButton = true,
testID,
}: {
header: SheetHeader;
onClose: () => void;
showCloseButton?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const back = header.back;
const handleBackPress = back?.onPress;
const search = header.search;
const handleSearchChange = useCallback(
(value: string) => {
search?.onChange(value);
},
[search],
);
return (
<View style={styles.headerContainer} testID={testID}>
<View style={styles.headerRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={18}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1}>
{header.title}
</Text>
{header.subtitle}
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
{({ pressed }) => (
<X
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
</View>
{search ? (
<View style={styles.searchRow}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
onChangeText={handleSearchChange}
autoCapitalize="none"
autoCorrect={false}
autoFocus={search.autoFocus}
testID={search.testID}
/>
</View>
) : null}
</View>
);
}
export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const back = header.back;
const handleBackPress = back?.onPress;
const hasInlineRow = Boolean(handleBackPress || header.leading);
if (!hasInlineRow && !header.search) return null;
return (
<View>
{hasInlineRow ? (
<View style={styles.inlineHeaderRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
</View>
) : null}
{header.search ? (
<View style={styles.inlineSearchRow}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}
onChangeText={header.search.onChange}
autoCapitalize="none"
autoCorrect={false}
autoFocus={header.search.autoFocus}
testID={header.search.testID}
/>
</View>
) : null}
</View>
);
}
export interface AdaptiveModalSheetProps {
title: string;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
header: SheetHeader;
visible: boolean;
onClose: () => void;
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
@@ -176,12 +412,10 @@ export interface AdaptiveModalSheetProps {
}
export function AdaptiveModalSheet({
title,
subtitle,
header,
visible,
onClose,
children,
headerActions,
snapPoints,
testID,
desktopMaxWidth,
@@ -190,7 +424,6 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
@@ -209,7 +442,6 @@ export function AdaptiveModalSheet({
[],
);
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
@@ -237,18 +469,7 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
@@ -266,18 +487,7 @@ export function AdaptiveModalSheet({
const cardInner = (
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
@@ -324,19 +534,3 @@ export function AdaptiveModalSheet({
</Modal>
);
}
/**
* TextInput that automatically uses BottomSheetTextInput on mobile
* for proper keyboard dodging in AdaptiveModalSheet.
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
if (isMobile) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}
return <TextInput ref={ref} {...props} />;
},
);

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import {
ShieldAlert,
ShieldCheck,
ShieldOff,
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
@@ -47,7 +48,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentFeature,
@@ -63,7 +64,7 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -180,6 +181,7 @@ const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
ShieldQuestionMark,
} as const;
function alwaysTrue() {
@@ -197,6 +199,16 @@ function resolveDisplayModel(
return findOptionLabel(modelOptions, selectedModelId, "Select model");
}
// Mobile status bar only — strip namespace prefix so providers like OpenCode
// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in
// the model picker.
function shortModelLabel(label: string): string {
const i = label.lastIndexOf("/");
return i === -1 ? label : label.slice(i + 1);
}
type ActiveSheet = "thinking" | "mode" | "features" | null;
function resolveHasAnyControl({
providerOptions,
modeOptions,
@@ -256,18 +268,6 @@ function makeBadgePressableStyle(
];
}
function makeSheetPressableStyle(isDisabled: boolean) {
return ({ pressed }: PressableStateCallbackType) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
isDisabled && styles.disabledSheetSelect,
];
}
function makePrefsButtonStyle({ pressed }: PressableStateCallbackType) {
return [styles.prefsButton, pressed && styles.prefsButtonPressed];
}
function pickSheetModel({
nextProviderId,
modelId,
@@ -418,27 +418,6 @@ function buildOpenChangeHandler(
};
}
function SheetModelTriggerView({
selectedModelLabel,
style,
ProviderIcon,
}: {
selectedModelLabel: string;
style: StyleProp<ViewStyle>;
ProviderIcon: ReturnType<typeof getProviderIcon> | null;
}) {
const { theme } = useUnistyles();
return (
<View style={style} pointerEvents="none" testID="agent-preferences-model">
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
);
}
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: {
@@ -490,7 +469,8 @@ function ControlledStatusBar({
onModelSelectorOpen,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const [prefsOpen, setPrefsOpen] = useState(false);
const isCompact = useIsCompactFormFactor();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null);
@@ -508,7 +488,6 @@ function ControlledStatusBar({
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId);
const displayThinking = findOptionLabel(
thinkingOptions,
@@ -568,6 +547,18 @@ function ControlledStatusBar({
),
[provider, providerDefinitions, theme.colors.foreground],
);
const renderThinkingOption = useCallback(
(args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => (
<ThinkingComboboxOption
option={args.option}
selected={args.selected}
active={args.active}
onPress={args.onPress}
iconColor={theme.colors.foreground}
/>
),
[theme.colors.foreground],
);
const handleOpenChange = useCallback(
(selector: StatusSelector) =>
@@ -641,16 +632,30 @@ function ControlledStatusBar({
[canSelectMode, disabled, openSelector],
);
const handleOpenPrefs = useCallback(() => {
const handleOpenSheet = useCallback((sheet: Exclude<ActiveSheet, null>) => {
Keyboard.dismiss();
setPrefsOpen(true);
setActiveSheet(sheet);
}, []);
const handleClosePrefs = useCallback(() => {
setPrefsOpen(false);
const handleCloseSheet = useCallback(() => {
setActiveSheet(null);
}, []);
const prefsButtonStyle = makePrefsButtonStyle;
const handleSelectThinkingAndClose = useCallback(
(thinkingOptionId: string) => {
onSelectThinkingOption?.(thinkingOptionId);
setActiveSheet(null);
},
[onSelectThinkingOption],
);
const handleSelectModeAndClose = useCallback(
(modeId: string) => {
onSelectMode?.(modeId);
setActiveSheet(null);
},
[onSelectMode],
);
const handleSheetModelSelect = useCallback(
(nextProviderId: string, modelId: string) => {
@@ -666,38 +671,13 @@ function ControlledStatusBar({
[onSelectModel, onSelectProvider, onSelectProviderAndModel, provider],
);
const sheetThinkingPressableStyle = useMemo(
() => makeSheetPressableStyle(disabled || !canSelectThinking),
[canSelectThinking, disabled],
);
const sheetModePressableStyle = useMemo(
() => makeSheetPressableStyle(disabled || !canSelectMode),
[canSelectMode, disabled],
);
const sheetSelectStyle = useMemo(
() => [styles.sheetSelect, modelDisabled && styles.disabledSheetSelect],
[modelDisabled],
);
const renderSheetModelTrigger = useCallback(
({ selectedModelLabel }: { selectedModelLabel: string }) => (
<SheetModelTriggerView
selectedModelLabel={selectedModelLabel}
style={sheetSelectStyle}
ProviderIcon={ProviderIcon}
/>
),
[ProviderIcon, sheetSelectStyle],
);
if (!hasAnyControl) {
return null;
}
return (
<View style={styles.container}>
{platformIsWeb ? (
{!isCompact ? (
<DesktopStatusBarContent
provider={provider}
providerOptions={providerOptions}
@@ -752,23 +732,19 @@ function ControlledStatusBar({
handleModeOpenChange={handleModeOpenChange}
handleOpenChange={handleOpenChange}
renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/>
) : (
<SheetStatusBarContent
provider={provider}
modeOptions={modeOptions}
selectedModeId={selectedModeId}
selectedModelId={selectedModelId}
thinkingOptions={thinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
features={features}
onSetFeature={onSetFeature}
onSelectMode={onSelectMode}
onSelectThinkingOption={onSelectThinkingOption}
onToggleFavoriteModel={onToggleFavoriteModel}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
providerDefinitions={providerDefinitions}
favoriteKeys={favoriteKeys}
disabled={disabled}
isModelLoading={isModelLoading}
@@ -779,24 +755,21 @@ function ControlledStatusBar({
modelDisabled={modelDisabled}
effectiveProviderDefinitions={effectiveProviderDefinitions}
effectiveAllProviderModels={effectiveAllProviderModels}
displayMode={displayMode}
displayModel={displayModel}
displayThinking={displayThinking}
comboboxModeOptions={comboboxModeOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
ModeIconComponent={ModeIconComponent}
modeIconColor={modeIconColor}
openSelector={openSelector}
ProviderIcon={ProviderIcon}
prefsOpen={prefsOpen}
handleOpenPrefs={handleOpenPrefs}
handleClosePrefs={handleClosePrefs}
prefsButtonStyle={prefsButtonStyle}
sheetThinkingPressableStyle={sheetThinkingPressableStyle}
sheetModePressableStyle={sheetModePressableStyle}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
handleCloseSheet={handleCloseSheet}
handleSheetModelSelect={handleSheetModelSelect}
handleThinkingOpenChange={handleThinkingOpenChange}
handleModeOpenChange={handleModeOpenChange}
handleSelectThinkingAndClose={handleSelectThinkingAndClose}
handleSelectModeAndClose={handleSelectModeAndClose}
handleOpenChange={handleOpenChange}
renderSheetModelTrigger={renderSheetModelTrigger}
renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/>
)}
</View>
@@ -862,6 +835,12 @@ interface DesktopStatusBarContentProps {
active: boolean;
onPress: () => void;
}) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
}
const DESKTOP_SEARCH_THRESHOLD = 6;
@@ -920,6 +899,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
handleModeOpenChange,
handleOpenChange,
renderModeOption,
renderThinkingOption,
} = props;
return (
@@ -1015,6 +995,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
onOpenChange={handleThinkingOpenChange}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
renderOption={renderThinkingOption}
/>
</>
) : null}
@@ -1074,19 +1055,14 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
interface SheetStatusBarContentProps {
provider: string;
modeOptions?: StatusOption[];
selectedModeId?: string;
selectedModelId?: string;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onSelectMode?: (modeId: string) => void;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
providerDefinitions: AgentProviderDefinition[];
favoriteKeys: Set<string>;
disabled: boolean;
isModelLoading: boolean;
@@ -1097,43 +1073,45 @@ interface SheetStatusBarContentProps {
modelDisabled: boolean;
effectiveProviderDefinitions: AgentProviderDefinition[];
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
displayMode: string;
displayModel: string;
displayThinking: string;
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
modeIconColor: string;
openSelector: StatusSelector | null;
ProviderIcon: ReturnType<typeof getProviderIcon> | null;
prefsOpen: boolean;
handleOpenPrefs: () => void;
handleClosePrefs: () => void;
prefsButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetThinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetModePressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
handleSheetModelSelect: (providerId: string, modelId: string) => void;
handleThinkingOpenChange: (open: boolean) => void;
handleModeOpenChange: (open: boolean) => void;
handleSelectThinkingAndClose: (thinkingOptionId: string) => void;
handleSelectModeAndClose: (modeId: string) => void;
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void;
renderSheetModelTrigger: (args: { selectedModelLabel: string }) => ReactElement;
renderModeOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
}
function SheetStatusBarContent(props: SheetStatusBarContentProps) {
const { theme } = useUnistyles();
const {
provider,
modeOptions,
selectedModeId,
selectedModelId,
thinkingOptions,
selectedThinkingOptionId,
features,
onSetFeature,
onSelectMode,
onSelectThinkingOption,
onToggleFavoriteModel,
onDropdownClose,
onModelSelectorOpen,
providerDefinitions,
favoriteKeys,
disabled,
isModelLoading,
@@ -1144,133 +1122,194 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
modelDisabled,
effectiveProviderDefinitions,
effectiveAllProviderModels,
displayMode,
displayModel,
displayThinking,
comboboxModeOptions,
comboboxThinkingOptions,
ModeIconComponent,
modeIconColor,
openSelector,
ProviderIcon,
prefsOpen,
handleOpenPrefs,
handleClosePrefs,
prefsButtonStyle,
sheetThinkingPressableStyle,
sheetModePressableStyle,
activeSheet,
handleOpenSheet,
handleCloseSheet,
handleSheetModelSelect,
handleThinkingOpenChange,
handleModeOpenChange,
handleSelectThinkingAndClose,
handleSelectModeAndClose,
handleOpenChange,
renderSheetModelTrigger,
renderModeOption,
renderThinkingOption,
} = props;
return (
<>
<Pressable
onPress={handleOpenPrefs}
style={prefsButtonStyle}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
const thinkingAnchorRef = useRef<View | null>(null);
const modeAnchorRef = useRef<View | null>(null);
const hasThinking = comboboxThinkingOptions.length > 0;
const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0);
const hasFeatures = Boolean(features && features.length > 0);
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
const handleOpenMode = useCallback(() => handleOpenSheet("mode"), [handleOpenSheet]);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
const handleThinkingSheetOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
handleOpenSheet("thinking");
} else {
handleCloseSheet();
}
},
[handleCloseSheet, handleOpenSheet],
);
const handleModeSheetOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
handleOpenSheet("mode");
} else {
handleCloseSheet();
}
},
[handleCloseSheet, handleOpenSheet],
);
const renderModelTrigger = useCallback(
({
selectedModelLabel,
}: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
}) => (
<View pointerEvents="none" style={styles.prefsButton} testID="agent-status-bar-model">
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel}
{shortModelLabel(selectedModelLabel)}
</Text>
</Pressable>
</View>
),
[ProviderIcon, theme.iconSize.lg, theme.colors.foregroundMuted],
);
const thinkingButtonStyle = makeBadgePressableStyle(
styles.modeIconBadge,
styles.disabledBadge,
disabled || !canSelectThinking,
activeSheet === "thinking",
);
const modeButtonStyle = makeBadgePressableStyle(
styles.modeIconBadge,
styles.disabledBadge,
disabled || !canSelectMode,
activeSheet === "mode",
);
const featuresButtonStyle = makeBadgePressableStyle(
styles.modeIconBadge,
styles.disabledBadge,
disabled,
activeSheet === "features",
);
return (
<>
{canSelectModel ? (
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderModelTrigger}
/>
) : null}
{hasThinking ? (
<Pressable
ref={thinkingAnchorRef}
onPress={handleOpenThinking}
disabled={disabled || !canSelectThinking}
style={thinkingButtonStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-status-bar-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
{hasMode ? (
<Pressable
ref={modeAnchorRef}
onPress={handleOpenMode}
disabled={disabled || !canSelectMode}
style={modeButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeId ?? ""})`}
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
) : null}
{hasFeatures ? (
<Pressable
onPress={handleOpenFeatures}
disabled={disabled}
style={featuresButtonStyle}
accessibilityRole="button"
accessibilityLabel="Open agent features"
testID="agent-status-bar-features"
>
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
{hasThinking ? (
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ""}
onSelect={handleSelectThinkingAndClose}
searchable={false}
title="Thinking"
open={activeSheet === "thinking"}
onOpenChange={handleThinkingSheetOpenChange}
anchorRef={thinkingAnchorRef}
renderOption={renderThinkingOption}
/>
) : null}
{hasMode ? (
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ""}
onSelect={handleSelectModeAndClose}
searchable={false}
title="Mode"
open={activeSheet === "mode"}
onOpenChange={handleModeSheetOpenChange}
renderOption={renderModeOption}
anchorRef={modeAnchorRef}
/>
) : null}
<AdaptiveModalSheet
title="Preferences"
visible={prefsOpen}
onClose={handleClosePrefs}
testID="agent-preferences-sheet"
header={FEATURES_SHEET_HEADER}
visible={activeSheet === "features"}
onClose={handleCloseSheet}
testID="agent-features-sheet"
>
{canSelectModel ? (
<View style={styles.sheetSection}>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderSheetModelTrigger}
/>
</View>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "thinking"}
onOpenChange={handleThinkingOpenChange}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={sheetThinkingPressableStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((thinking) => (
<ThinkingMenuItem
key={thinking.id}
thinking={thinking}
selected={thinking.id === selectedThinkingOptionId}
onSelectThinkingOption={onSelectThinkingOption}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu open={openSelector === "mode"} onOpenChange={handleModeOpenChange}>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={sheetModePressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => (
<ModeMenuItem
key={mode.id}
mode={mode}
provider={provider}
providerDefinitions={providerDefinitions}
selected={mode.id === selectedModeId}
onSelectMode={onSelectMode}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => (
{(features ?? []).map((feature) => (
<SheetFeatureItem
key={`feature-${feature.id}`}
feature={feature}
@@ -1537,23 +1576,28 @@ function FeatureOptionMenuItem({
);
}
function ThinkingMenuItem({
thinking,
function ThinkingComboboxOption({
option,
selected,
onSelectThinkingOption,
active,
onPress,
iconColor,
}: {
thinking: StatusOption;
option: ComboboxOption;
selected: boolean;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
active: boolean;
onPress: () => void;
iconColor: string;
}) {
const handleSelect = useCallback(() => {
onSelectThinkingOption?.(thinking.id);
}, [onSelectThinkingOption, thinking.id]);
const leadingSlot = useMemo(() => <Brain size={16} color={iconColor} />, [iconColor]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{thinking.label}
</DropdownMenuItem>
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
@@ -1591,40 +1635,8 @@ function ModeComboboxOption({
);
}
function ModeMenuItem({
mode,
provider,
providerDefinitions,
selected,
onSelectMode,
}: {
mode: StatusOption;
provider: string;
providerDefinitions: AgentProviderDefinition[];
selected: boolean;
onSelectMode?: (modeId: string) => void;
}) {
const { theme } = useUnistyles();
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const handleSelect = useCallback(() => {
onSelectMode?.(mode.id);
}, [mode.id, onSelectMode]);
const leadingIcon = useMemo(
() => <Icon size={16} color={theme.colors.foreground} />,
[Icon, theme.colors.foreground],
);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leadingIcon}>
{mode.label}
</DropdownMenuItem>
);
}
const EMPTY_MODES: AgentMode[] = [];
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentStatusBar = memo(function AgentStatusBar({
agentId,
@@ -1873,6 +1885,7 @@ export function DraftAgentStatusBar({
disabled = false,
}: DraftAgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -1920,7 +1933,7 @@ export function DraftAgentStatusBar({
[updatePreferences],
);
if (platformIsWeb) {
if (!isCompact) {
return (
<View style={styles.container}>
<CombinedModelSelector
@@ -2034,9 +2047,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
prefsButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,

View File

@@ -35,6 +35,7 @@ describe("buildAgentStreamRenderModel", () => {
const head = [assistantMessage("live-a", 121)];
const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head,
platform: "web",
@@ -52,6 +53,7 @@ describe("buildAgentStreamRenderModel", () => {
const head = [assistantMessage("live-a", 3)];
const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head,
platform: "web",
@@ -69,12 +71,14 @@ describe("buildAgentStreamRenderModel", () => {
const secondHead = [assistantMessage("live-b", 4)];
const first = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head: firstHead,
platform: "native",
isMobileBreakpoint: false,
});
const second = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head: secondHead,
platform: "native",
@@ -85,4 +89,73 @@ describe("buildAgentStreamRenderModel", () => {
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
});
it("derives running turn timing across committed history and live head", () => {
const tail = [userMessage("u1", 1)];
const head = [assistantMessage("live-a", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "running",
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
});
it("maps completed turn timing to assistant ids across committed history and live head", () => {
const tail = [userMessage("u1", 1)];
const head = [assistantMessage("live-a", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
});
it("derives the same timing for native inverted rendering", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head: [],
platform: "native",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
startedAt: tail[0]?.timestamp,
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
});
it("does not create completed timing for adjacent user messages", () => {
const tail = [userMessage("u1", 1), userMessage("u2", 4)];
const model = buildAgentStreamRenderModel({
agentStatus: "idle",
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
});

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
@@ -26,17 +27,19 @@ export interface StreamHistoryBoundary {
export interface StreamRenderAuxiliary {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
turnFooter: ReactNode;
}
export interface AgentStreamRenderModel {
history: StreamItem[];
segments: StreamRenderSegments;
turnTiming: StreamTurnTiming;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
}
export interface BuildAgentStreamRenderModelInput {
agentStatus: string;
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
@@ -46,7 +49,7 @@ export interface BuildAgentStreamRenderModelInput {
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null,
workingIndicator: null,
turnFooter: null,
};
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
@@ -55,6 +58,10 @@ const splitHistoryCache = new WeakMap<
StreamItem[],
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
>();
const turnTimingCache = new WeakMap<
StreamItem[],
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
>();
function getOrderedItems(params: {
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
@@ -127,6 +134,30 @@ function splitOrderedTail(params: {
return split;
}
function getTurnTiming(params: {
agentStatus: string;
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
let cachedByHead = turnTimingCache.get(params.tail);
if (!cachedByHead) {
cachedByHead = new WeakMap();
turnTimingCache.set(params.tail, cachedByHead);
}
let cachedByStatus = cachedByHead.get(params.head);
if (!cachedByStatus) {
cachedByStatus = new Map();
cachedByHead.set(params.head, cachedByStatus);
}
const cached = cachedByStatus.get(params.agentStatus);
if (cached) {
return cached;
}
const timing = deriveStreamTurnTiming(params);
cachedByStatus.set(params.agentStatus, timing);
return timing;
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
@@ -160,6 +191,11 @@ export function buildAgentStreamRenderModel(
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
const turnTiming = getTurnTiming({
agentStatus: input.agentStatus,
tail: input.tail,
head: input.head,
});
return {
history: splitHistory.history,
@@ -167,6 +203,7 @@ export function buildAgentStreamRenderModel(
...splitHistory.segments,
liveHead: orderedHead,
},
turnTiming,
boundary: {
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,

View File

@@ -0,0 +1,222 @@
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
import { SyncedLoader } from "./synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const usesLiveHead = input.liveHead.length > 0;
const footerItems = usesLiveHead ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
}) {
if (isRunning) {
return (
<TurnFooterRow>
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
</TurnFooterRow>
);
}
if (!host) {
return null;
}
return (
<CompletedTurnFooterRow
strategy={strategy}
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
/>
);
});
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
return (
<TurnFooterRow>
<CompletedTurnFooter
strategy={strategy}
items={items}
timing={timing}
startIndex={startIndex}
/>
</TurnFooterRow>
);
});
const WorkingIndicator = memo(function WorkingIndicator({
inFlightTurnStartedAt = null,
}: {
inFlightTurnStartedAt?: Date | null;
}) {
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingLoader}>
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
</View>
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
});
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
return (
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
);
}
function CompletedTurnFooter({
strategy,
items,
timing,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
/>
</View>
);
}
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
paddingBottom: theme.spacing[6],
},
turnFooterContent: {
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
workingElapsed: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
fontVariant: ["tabular-nums"],
},
workingLoader: {
marginLeft: -2,
},
}));

View File

@@ -1,20 +1,6 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { NeighborResolver } from "./agent-stream-view-data";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
// Minimal forward-order resolver: "below" = next item in array.
// Matches web strategy rendering order (chronological, top-to-bottom).
const forwardStrategy: NeighborResolver = {
getNeighborItem(items, index, relation) {
const neighborIndex = relation === "below" ? index + 1 : index - 1;
return items[neighborIndex];
},
};
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
function assistantBlock(params: {
id: string;
@@ -135,46 +121,3 @@ describe("getAssistantBlockSpacing", () => {
).toBe("compactTop");
});
});
describe("resolveInlineWorkingIndicatorItemId", () => {
it("returns null when the agent is not running", () => {
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
});
it("returns the last assistant block id when running with a single head block", () => {
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
"group-1:head",
);
});
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
const tc = toolCallBlock("tool-1");
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
});
it("returns the footer assistant block when history and streaming head coexist", () => {
const historyBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
});
const streamingBlock = assistantBlock({
id: "group-2:head",
blockGroupId: "group-2",
blockIndex: 0,
});
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
"group-2:head",
);
expect(
resolveInlineWorkingIndicatorItemId(
"running",
[historyBlock, streamingBlock],
forwardStrategy,
),
).toBe("group-2:head");
});
});

View File

@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined;
@@ -28,24 +29,35 @@ export function getAssistantBlockSpacing(params: {
return "default";
}
export interface NeighborResolver {
getNeighborItem(
items: StreamItem[],
index: number,
relation: "above" | "below",
): StreamItem | undefined;
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
// null → auxiliary working indicator; non-null → inline footer on that block.
export function resolveInlineWorkingIndicatorItemId(
status: string,
liveHeadItems: StreamItem[],
strategy: NeighborResolver,
): string | null {
if (status !== "running") return null;
const footerItem = liveHeadItems.find((item, index, items) => {
if (item.kind !== "assistant_message") return false;
return strategy.getNeighborItem(items, index, "below") === undefined;
});
return footerItem?.id ?? null;
export function getGapBetweenStreamItems(
item: StreamItem | null,
belowItem: StreamItem | null,
): number {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return SPACING[3];
}
return SPACING[4];
}

View File

@@ -17,20 +17,13 @@ import {
Platform,
ActivityIndicator,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, {
FadeIn,
FadeOut,
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import {
@@ -41,7 +34,6 @@ import {
ToolCall,
TodoListCard,
CompactionMarker,
TurnCopyButton,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
@@ -63,42 +55,182 @@ import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
collectAssistantTurnContentForStreamRenderStrategy,
getStreamNeighborItem,
resolveStreamRenderStrategy,
type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
CompletedTurnFooterRow,
resolveBottomTurnFooterHost,
shouldRenderCompletedTurnFooter,
TurnFooter,
type TurnContentStrategy,
type TurnFooterHost,
} from "./agent-stream-turn-footer";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
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 {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
import type { Theme } from "@/styles/theme";
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
}): ReactNode {
if (!input.pendingPermissions && !input.turnFooter) {
return null;
}
return (
<>
{input.turnFooter}
{input.pendingPermissions ? (
<View style={stylesheet.contentWrapper}>
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
</View>
) : null}
</>
);
}
function renderPendingPermissionsNode(input: {
pendingPermissions: PendingPermission[];
client: DaemonClient | null;
}): ReactNode {
if (input.pendingPermissions.length === 0) {
return null;
}
return (
<View style={stylesheet.permissionsContainer}>
{input.pendingPermissions.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={input.client} />
))}
</View>
);
}
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
item: StreamItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
strategy: TurnContentStrategy;
}): ReactNode {
if (!input.content) {
return null;
}
const showCompletedFooter = shouldRenderCompletedTurnFooter({
item: input.item,
belowItem: input.nextItem,
agentStatus: input.agentStatus,
suppressTurnFooter: input.suppressTurnFooter,
});
const gapBelow = showCompletedFooter
? 0
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
return (
<>
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
{showCompletedFooter ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
</>
);
}
function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>;
}): ReactNode {
if (
input.renderModel.boundary.hasVirtualizedHistory ||
input.renderModel.boundary.hasMountedHistory ||
input.renderModel.boundary.hasLiveHead ||
input.renderModel.auxiliary.pendingPermissions ||
input.renderModel.auxiliary.turnFooter
) {
return null;
}
return (
<View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}
function renderHistoryStreamItem(input: {
item: StreamItem;
historyIndexById: Map<string, number>;
historyItems: StreamItem[];
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
const historyIndex = input.historyIndexById.get(input.item.id);
if (historyIndex === undefined) {
return null;
}
const seamBelowItem =
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
belowItem: seamBelowItem,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
index: number;
items: StreamItem[];
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
return input.renderStreamItem(input.item, input.index, input.items, {
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
export interface AgentStreamViewHandle {
@@ -115,9 +247,11 @@ 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[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -173,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";
@@ -194,7 +323,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [agentId]);
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
}
@@ -205,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;
}
@@ -214,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
target: createWorkspaceFileTabTarget(location),
});
}
return;
@@ -245,35 +386,26 @@ 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],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
agentStatus: agent.status,
tail: streamItems,
head: streamHead ?? [],
head: streamHead ?? EMPTY_STREAM_HEAD,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(
() =>
resolveInlineWorkingIndicatorItemId(
agent.status,
baseRenderModel.segments.liveHead,
streamRenderStrategy,
),
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
);
}, [agent.status, isMobile, streamHead, streamItems]);
useImperativeHandle(
ref,
() => ({
@@ -291,39 +423,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return looseGap;
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return assistantBlockGap;
}
return looseGap;
},
[assistantBlockGap, looseGap, tightGap],
);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
@@ -418,11 +517,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
toast={toast}
spacing={spacing}
/>
);
},
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
);
const renderThoughtItem = useCallback(
@@ -540,7 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return <TodoListCard items={item.items} />;
case "compaction":
return <CompactionMarker status={item.status} preTokens={item.preTokens} />;
return (
<CompactionMarker
status={item.status}
trigger={item.trigger}
preTokens={item.preTokens}
/>
);
default:
return null;
@@ -549,6 +655,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const bottomTurnFooterHost = useMemo(() => {
return resolveBottomTurnFooterHost({
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
]);
const renderStreamItem = useCallback(
(
item: StreamItem,
@@ -557,45 +679,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seams);
if (!content) {
return null;
}
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const gapBelow = getGapBetween(item, nextItem ?? null);
const isEndOfAssistantTurn =
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const isRunningAssistantTurnFooter =
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
let footer: ReactNode = null;
if (isRunningAssistantTurnFooter) {
footer = <InlineWorkingIndicatorSlot />;
} else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
);
}
return (
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{footer}
</StreamItemWrapper>
);
return renderStreamItemWithTurnFooter({
content,
item,
nextItem,
items,
timing: baseRenderModel.turnTiming.byAssistantId,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
strategy: streamRenderStrategy,
});
},
[
getGapBetween,
renderStreamItemContent,
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
],
);
@@ -604,66 +710,56 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const showRunningTurnFooter = agent.status === "running";
const pendingPermissionsNode = useMemo(
() =>
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
))}
</View>
) : null,
renderPendingPermissionsNode({
pendingPermissions: pendingPermissionItems,
client,
}),
[client, pendingPermissionItems],
);
const workingIndicatorNode = useMemo(
const turnFooterNode = useMemo(
() =>
showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
<WorkingIndicator />
</View>
showRunningTurnFooter || bottomTurnFooterHost ? (
<TurnFooter
isRunning={showRunningTurnFooter}
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
/>
) : null,
[showAuxiliaryWorkingIndicator],
[
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
streamRenderStrategy,
],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetween(
historyToHeadGap: getGapBetweenStreamItems(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
workingIndicator: workingIndicatorNode,
turnFooter: turnFooterNode,
},
};
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(() => {
if (
renderModel.boundary.hasVirtualizedHistory ||
renderModel.boundary.hasMountedHistory ||
renderModel.boundary.hasLiveHead ||
renderModel.auxiliary.pendingPermissions ||
renderModel.auxiliary.workingIndicator
) {
return null;
}
return (
<View style={emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}, [renderModel, emptyStateStyle]);
const listEmptyComponent = useMemo(
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
[renderModel, emptyStateStyle],
);
const historyItems = renderModel.history;
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
@@ -677,17 +773,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [historyItems]);
const renderHistoryRow = useCallback(
(item: StreamItem) => {
const historyIndex = historyIndexById.get(item.id);
if (historyIndex === undefined) {
return null;
}
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
return renderStreamItem(item, historyIndex, historyItems, {
belowItem: seamBelowItem,
});
},
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
(item: StreamItem) =>
renderHistoryStreamItem({
item,
historyIndexById,
historyItems,
lastHistoryItem,
firstLiveHeadItem,
bottomTurnFooterHost,
renderStreamItem,
}),
[
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -699,32 +802,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, {
aboveItem: index === 0 ? lastHistoryItem : null,
renderLiveHeadStreamItem({
item,
index,
items,
lastHistoryItem,
bottomTurnFooterHost,
renderStreamItem,
}),
[lastHistoryItem, renderStreamItem],
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
);
const liveAuxiliaryHeaderStyle = useMemo(() => {
let headerPadding: { paddingBottom: number } | { paddingTop: number } | null;
if (!boundary.hasLiveHead) headerPadding = null;
else if (streamRenderStrategy.getFlatListInverted())
headerPadding = { paddingBottom: looseGap };
else headerPadding = { paddingTop: looseGap };
return [stylesheet.listHeaderContent, headerPadding];
}, [boundary.hasLiveHead, streamRenderStrategy, looseGap]);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View style={liveAuxiliaryHeaderStyle}>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
return renderLiveAuxiliaryNode({
pendingPermissions: auxiliary.pendingPermissions,
turnFooter: auxiliary.turnFooter,
});
}, [auxiliary.pendingPermissions, auxiliary.turnFooter]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
@@ -796,106 +889,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={containerStyle}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
</View>
);
}
function InlineWorkingIndicatorSlot() {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
@@ -1192,6 +1185,7 @@ const stylesheet = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
listContentContainer: {
paddingVertical: 0,
@@ -1212,6 +1206,7 @@ const stylesheet = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
emptyState: {
flex: 1,
@@ -1225,51 +1220,6 @@ const stylesheet = StyleSheet.create((theme) => ({
listHeaderContent: {
gap: theme.spacing[3],
},
bottomBarWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
marginTop: theme.spacing[4],
paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
inlineTurnFooter: {
alignSelf: "flex-start",
marginTop: theme.spacing[2],
padding: theme.spacing[2],
paddingTop: 0,
},
inlineWorkingIndicatorFrame: {
height: 18,
alignItems: "center",
justifyContent: "center",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: 0,
paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[2],
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
borderWidth: 0,
alignSelf: "flex-start",
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.foregroundMuted,
},
syncingIndicator: {
flexDirection: "row",
alignItems: "center",

View File

@@ -3,6 +3,7 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"
import {
buildModelRows,
buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
@@ -83,6 +84,34 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(row, "kimi gemini")).toBe(false);
});
it("ranks model search results by fuzzy match quality", () => {
const rows = [
{
favoriteKey: "openai:gpt-4.1",
provider: "openai",
providerLabel: "OpenAI",
modelId: "gpt-4.1",
modelLabel: "GPT-4.1",
},
{
favoriteKey: "openai:gpt-5.4",
provider: "openai",
providerLabel: "OpenAI",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
},
{
favoriteKey: "google:gemini",
provider: "google",
providerLabel: "Google",
modelId: "gemini",
modelLabel: "Gemini",
},
];
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
});
it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
@@ -19,6 +19,7 @@ import {
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter,
type AttachmentPersister,
type ComposerCancelClient,
@@ -696,6 +697,36 @@ describe("toggleGithubAttachment", () => {
});
});
describe("toggleGithubAttachmentFromPicker", () => {
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
const markGithubAttachmentRemoved = vi.fn();
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
const next = toggleGithubAttachmentFromPicker({
current: [attachment],
item: prItem,
markGithubAttachmentRemoved,
});
expect(next).toEqual([]);
expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1);
expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment);
});
it("does not mark a GitHub item removed when picker toggle adds it", () => {
const markGithubAttachmentRemoved = vi.fn();
const next = toggleGithubAttachmentFromPicker({
current: [],
item: issueItem,
markGithubAttachmentRemoved,
});
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
});
});
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);

View File

@@ -305,6 +305,29 @@ export function toggleGithubAttachment(
return [...current, buildGithubAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
export function toggleGithubAttachmentFromPicker({
current,
item,
markGithubAttachmentRemoved,
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
optionId: string,

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

@@ -21,7 +21,6 @@ import {
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import {
AgentStatusBar,
@@ -53,7 +52,7 @@ import {
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
@@ -97,6 +96,10 @@ import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
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;
@@ -137,6 +140,20 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
function resolveGithubSearchEnabled(
isGithubPickerOpen: boolean,
isConnected: boolean,
cwd: string,
): boolean {
return isGithubPickerOpen && isConnected && cwd.trim().length > 0;
}
function resolveCheckoutRemoteUrl(
checkoutStatus: ReturnType<typeof useCheckoutStatusQuery>["status"],
): string | null {
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
@@ -164,31 +181,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
};
}
interface BuildGithubSearchQueryOptionsArgs {
serverId: string;
cwd: string;
githubSearchQueryTrimmed: string;
isGithubPickerOpen: boolean;
isConnected: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
}
function buildGithubSearchQueryOptions(args: BuildGithubSearchQueryOptionsArgs) {
const { serverId, cwd, githubSearchQueryTrimmed, isGithubPickerOpen, isConnected, client } = args;
const hasClient = Boolean(client);
const cwdIsSet = cwd.trim().length > 0;
const enabled = isGithubPickerOpen && isConnected && hasClient && cwdIsSet;
return {
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
queryFn: async () => {
if (!client) throw new Error("Host is not connected");
return client.searchGitHub({ cwd, query: githubSearchQueryTrimmed, limit: 20 });
},
enabled,
staleTime: 30_000,
};
}
function renderContextWindowMeterSlot(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
@@ -616,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. */
@@ -748,6 +741,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isAgentRunning: boolean;
hasSendableContent: boolean;
isProcessing: boolean;
isCompact: boolean;
cancelButton: ReactElement;
}
@@ -757,10 +751,12 @@ function ComposerRightControlsSlot({
isAgentRunning,
hasSendableContent,
isProcessing,
isCompact,
cancelButton,
...voiceProps
}: ComposerRightControlsSlotProps) {
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
if (!showVoiceModeButton && !shouldShowCancelButton) return null;
return (
@@ -816,6 +812,7 @@ export function Composer({
serverId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
@@ -890,6 +887,17 @@ export function Composer({
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client,
isConnected,
serverId,
cwd,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
@@ -905,6 +913,41 @@ export function Composer({
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
return false;
}
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
clearDraft("sent");
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
setIsProcessing(true);
void onClientSlashCommand(command)
.catch((error) => {
console.error("[Composer] Failed to run client slash command:", error);
setSendError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
setIsProcessing(false);
});
return true;
},
[
blurOnSubmit,
clearDraft,
onClientSlashCommand,
resetSuppression,
setSelectedAttachments,
setUserInput,
],
);
const autocomplete = useAgentAutocomplete({
userInput,
cursorIndex,
@@ -912,6 +955,8 @@ export function Composer({
serverId,
agentId,
draftConfig: commandDraftConfig,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
onAutocompleteApplied: () => {
messageInputRef.current?.focus();
},
@@ -1107,16 +1152,27 @@ export function Composer({
const handleSubmit = useCallback(
(payload: MessagePayload) => {
const outgoingAttachments = buildOutgoingAttachments(attachments);
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(
payload.text,
buildOutgoingAttachments(attachments),
payload.forceSend,
);
void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend);
},
[attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent],
[
attachments,
blurOnSubmit,
buildOutgoingAttachments,
runClientSlashCommand,
sendMessageWithContent,
],
);
const handlePickImage = useCallback(async () => {
@@ -1135,6 +1191,7 @@ export function Composer({
const handleRemoveAttachment = useCallback(
(index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
@@ -1146,7 +1203,7 @@ export function Composer({
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
@@ -1277,18 +1334,25 @@ export function Composer({
const handleQueue = useCallback(
(payload: MessagePayload) => {
queueMessage(payload.text, buildOutgoingAttachments(attachments));
const outgoingAttachments = buildOutgoingAttachments(attachments);
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
queueMessage(payload.text, outgoingAttachments);
},
[attachments, buildOutgoingAttachments, queueMessage],
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
return autocompleteOnKeyPressRef.current(event);
},
(event: { key: string; preventDefault: () => void }) =>
autocompleteOnKeyPressRef.current(event),
[],
);
@@ -1340,6 +1404,7 @@ export function Composer({
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
isCompact={isMobile}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
@@ -1357,6 +1422,7 @@ export function Composer({
hasSendableContent,
isAgentRunning,
isConnected,
isMobile,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,
@@ -1376,16 +1442,13 @@ export function Composer({
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useQuery(
buildGithubSearchQueryOptions({
serverId,
cwd,
githubSearchQueryTrimmed,
isGithubPickerOpen,
isConnected,
client,
}),
);
const githubSearchResultsQuery = useGithubSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
const githubSearchItemsRaw = githubSearchResultsQuery.data?.items;
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
@@ -1423,11 +1486,22 @@ export function Composer({
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
setSelectedAttachments((current) => toggleGithubAttachment(current, item));
const nextAttachments = toggleGithubAttachmentFromPicker({
current: attachments,
item,
markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved,
});
setSelectedAttachments(nextAttachments);
setIsGithubPickerOpen(false);
setGithubSearchQuery("");
},
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
[
attachments,
githubAutoAttach,
setSelectedAttachments,
setGithubSearchQuery,
setIsGithubPickerOpen,
],
);
const leftContent = useMemo(

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

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

View File

@@ -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 />
@@ -791,10 +811,13 @@ function DesktopSidebar({
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle, insetsTop],
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
[resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
);
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[],
@@ -885,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

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { getCompactionMarkerLabel } from "./message-compaction-label";
describe("getCompactionMarkerLabel", () => {
it("renders loading, automatic, manual, tokenized, and fallback labels", () => {
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("Compacting...");
expect(getCompactionMarkerLabel({ status: "completed", trigger: "auto" })).toBe(
"Context automatically compacted",
);
expect(getCompactionMarkerLabel({ status: "completed", trigger: "manual" })).toBe(
"Context manually compacted",
);
expect(getCompactionMarkerLabel({ status: "completed", preTokens: 12_345 })).toBe(
"Context compacted (12K tokens)",
);
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
});
});

View File

@@ -0,0 +1,17 @@
export interface CompactionMarkerLabelInput {
status: "loading" | "completed";
trigger?: "auto" | "manual";
preTokens?: number;
}
export function getCompactionMarkerLabel({
status,
trigger,
preTokens,
}: CompactionMarkerLabelInput): string {
if (status === "loading") return "Compacting...";
if (trigger === "auto") return "Context automatically compacted";
if (trigger === "manual") return "Context manually compacted";
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
return "Context compacted";
}

View File

@@ -3,6 +3,7 @@ import {
Text,
TextInput,
ActivityIndicator,
useWindowDimensions,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
@@ -47,11 +48,13 @@ import {
} from "@/components/ui/dropdown-menu";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
import { formatShortcut } from "@/utils/format-shortcut";
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";
@@ -142,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<
@@ -364,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;
@@ -390,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) {
@@ -979,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,
};
}
@@ -1186,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();
@@ -1450,7 +1464,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback(
() =>
sendMessageImpl({
value,
value: valueRef.current,
attachments,
hasExternalContent,
allowEmptySubmit,
@@ -1461,7 +1475,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}),
[
allowEmptySubmit,
value,
attachments,
cwd,
onSubmit,
@@ -1474,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(() => {
@@ -1518,7 +1531,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
enabled: isWeb,
});
usePasteImagesEffect({
@@ -1532,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,
});
@@ -1566,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,
@@ -1598,6 +1617,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
defaultSendBehavior,
isAgentRunning,
});
useIosHardwareKeyboardSubmit({
isEnabled: isInputFocused && !isSendButtonDisabled,
onSubmit: handleDefaultSendAction,
});
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel,
canPressLoadingButton,
@@ -1618,6 +1641,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback(
(nextValue: string) => {
valueRef.current = nextValue;
onChangeText(nextValue);
},
[onChangeText],
@@ -1663,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],
@@ -1716,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}
/>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -106,6 +106,7 @@ import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
@@ -593,7 +594,7 @@ function ProjectKebabMenu({
<DropdownMenuTrigger
hitSlop={8}
style={projectKebabStyle}
accessibilityRole="button"
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`}
>
@@ -724,7 +725,7 @@ function WorkspaceKebabMenu({
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole="button"
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
@@ -894,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}
>
@@ -1172,10 +1173,13 @@ function ProjectHeaderRow({
return;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href,
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
displayName,
projectId: project.projectKey,
}) as Href,
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const _toast = useToast();
@@ -1183,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) {
@@ -1246,7 +1256,7 @@ function ProjectHeaderRow({
if (menuController) {
return (
<View
{...dragHandleProps?.attributes}
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter}
@@ -1254,6 +1264,7 @@ function ProjectHeaderRow({
>
<ContextMenuTrigger
enabledOnMobile={false}
accessibilityRole="button"
style={projectRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
@@ -1269,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}
@@ -1324,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) {
@@ -1365,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}
@@ -1474,92 +1492,93 @@ function WorkspaceRowWithMenu({
});
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => {
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Archive worktree?",
message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
if (!confirmed) {
return;
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!confirmed) {
return;
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!archiveDirectory) {
toast.error("Workspace path not available");
return;
}
if (!archiveDirectory) {
toast.error("Workspace path not available");
return;
}
redirectAfterArchive();
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
})();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
void (async () => {
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
void hideWorkspaceAfterConfirmation();
}, [hideWorkspaceAfterConfirmation]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
@@ -1589,7 +1608,7 @@ function WorkspaceRowWithMenu({
priority: 0,
handle: () => {
if (isWorktree) {
handleArchiveWorktree();
void archiveWorktreeAfterConfirmation();
} else {
handleArchiveWorkspace();
}

View File

@@ -23,4 +23,13 @@ describe("shouldFocusPaneFromEventTarget", () => {
).toBe(true);
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
});
it("returns true for composer text inputs so focusing the composer focuses the pane", () => {
expect(
shouldFocusPaneFromEventTarget({
closest: (selector: string) =>
selector.includes("input") ? ({ tagName: "INPUT" } as Element) : null,
} as unknown as EventTarget),
).toBe(true);
});
});

View File

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

View File

@@ -67,7 +67,7 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps {
@@ -406,6 +406,7 @@ export function SplitContainer({
}
return { kind: "pane" as const, pane: focusedPane };
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
@@ -560,7 +561,7 @@ export function SplitContainer({
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={effectiveRoot}
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
@@ -1071,6 +1072,22 @@ function getNodeKey(node: SplitNode): string {
return node.group.id;
}
function wrapRootPaneForStableMount(node: SplitNode): SplitNode {
if (node.kind === "group") {
return node;
}
return {
kind: "group",
group: {
id: `root:${node.pane.id}`,
direction: "horizontal",
children: [node],
sizes: [1],
},
};
}
const styles = StyleSheet.create((theme) => ({
group: {
flex: 1,

View File

@@ -17,6 +17,7 @@ import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -115,6 +116,7 @@ interface TerminalEmulatorProps {
streamKey: string;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
@@ -129,6 +131,7 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
@@ -186,6 +189,7 @@ export default function TerminalEmulator({
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
@@ -194,6 +198,7 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
@@ -203,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);
@@ -219,12 +226,14 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
});
mountCallbacksRef.current = {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
@@ -284,6 +293,10 @@ export default function TerminalEmulator({
runtimeRef.current?.setTheme({ theme: nextTheme });
}, [themeKey]);
useEffect(() => {
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => {
ensureTerminalScrollbarStyle();
}, []);
@@ -424,6 +437,7 @@ export default function TerminalEmulator({
root,
host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
});
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
@@ -444,10 +458,11 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onOpenExternalUrl: openExternalUrl,
},
});
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });

View File

@@ -10,6 +10,7 @@ import {
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible";
@@ -34,6 +35,7 @@ import {
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
import { useAppSettings } from "@/hooks/use-settings";
interface TerminalPaneProps {
serverId: string;
@@ -156,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);
@@ -188,6 +191,10 @@ export function TerminalPane({
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const inputModeRef = useRef<TerminalInputModeState>({
kittyKeyboardFlags: 0,
win32InputMode: false,
});
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
@@ -195,6 +202,10 @@ export function TerminalPane({
useEffect(() => {
terminalIdRef.current = terminalId;
inputModeRef.current = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
}, [terminalId]);
const requestTerminalFocus = useCallback(() => {
@@ -413,7 +424,9 @@ export function TerminalPane({
return true;
}
const encoded = encodeTerminalKeyInput(entry.input);
const encoded = encodeTerminalKeyInput(entry.input, {
inputMode: inputModeRef.current,
});
if (encoded.length === 0) {
return true;
}
@@ -595,6 +608,10 @@ export function TerminalPane({
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
@@ -666,6 +683,7 @@ export function TerminalPane({
streamKey={terminalStreamKey}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -674,6 +692,7 @@ export function TerminalPane({
onInput={handleTerminalData}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}

View File

@@ -121,6 +121,16 @@ describe("filterAndRankComboboxOptions", () => {
const result = filterAndRankComboboxOptions(items, "202");
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
});
it("matches fuzzy character sequences after stronger substring matches", () => {
const items = [
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-4.1", label: "GPT-4.1" },
{ id: "gemini", label: "Gemini" },
];
const result = filterAndRankComboboxOptions(items, "gpt54");
expect(result.map((o) => o.id)).toEqual(["gpt-5.4"]);
});
});
describe("combobox above-search ordering", () => {

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: GitHubSearchItem = {
kind: "pr",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature",
};
const issue202: GitHubSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
url: "https://github.com/acme/paseo/issues/202",
state: "open",
body: null,
labels: [],
baseRefName: null,
headRefName: null,
};
interface SearchCall {
cwd: string;
query: string;
limit?: number;
}
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialText?: string;
remote?: string | null;
}
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
return {
items,
githubFeaturesEnabled: true,
error: null,
requestId,
};
}
function createSearchClient(
items: GitHubSearchItem[],
): GitHubSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchGitHub(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
};
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
);
const autoAttach = useComposerGithubAutoAttach({
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client,
isConnected: true,
serverId: "server-1",
cwd,
setAttachments,
});
return {
text,
setText,
attachments,
setAttachments,
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
};
}
async function flushDebounce() {
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
await Promise.resolve();
});
}
describe("useComposerGithubAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
it("ignores URLs that do not match the current remote", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Other repo https://github.com/other/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not add a second pill when the ref is already attached", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Already here https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual(initialAttachments);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not re-add a GitHub ref removed earlier in the same composer session", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.markGithubAttachmentRemoved(initialAttachments[0]);
result.current.setAttachments([]);
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("handles multiple matching URLs from one paste", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101, issue202]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202",
);
});
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "github_pr", item: pr101 },
{ kind: "github_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
]);
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,240 @@
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useCallback,
useEffect,
useMemo,
useRef,
type Dispatch,
type RefObject,
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildGithubSearchQueryOptions,
type GitHubSearchClient,
} from "@/git/use-github-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@server/shared/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: GitHubSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
}
interface ComposerGithubAutoAttachResult {
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
}
export function useComposerGithubAutoAttach(
params: ComposerGithubAutoAttachInput,
): ComposerGithubAutoAttachResult {
const queryClient = useQueryClient();
const latestRef = useRef(params);
const removedRefKeysRef = useRef(new Set<string>());
const pendingRefKeysRef = useRef(new Set<string>());
latestRef.current = params;
useEffect(() => {
const refs = refsReadyForLookup({
params: latestRef.current,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
if (refs.length === 0) {
return;
}
const timerId = setTimeout(() => {
void attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current,
});
}, AUTO_ATTACH_DEBOUNCE_MS);
return () => {
clearTimeout(timerId);
};
}, [
params.text,
params.remoteUrl,
params.attachments,
params.client,
params.isConnected,
params.serverId,
params.cwd,
queryClient,
]);
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
const key = attachmentKey(attachment);
if (key) {
removedRefKeysRef.current.add(key);
}
}, []);
return useMemo(
() => ({
markGithubAttachmentRemoved,
}),
[markGithubAttachmentRemoved],
);
}
async function attachRefs({
refs,
queryClient,
latestRef,
removedRefKeys,
pendingRefKeys,
}: {
refs: GithubRef[];
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): Promise<void> {
for (const ref of refs) {
const key = githubRefKey(ref);
if (pendingRefKeys.has(key)) {
continue;
}
pendingRefKeys.add(key);
try {
await attachRef({ ref, key, queryClient, latestRef, removedRefKeys });
} finally {
pendingRefKeys.delete(key);
}
}
}
async function attachRef({
ref,
key,
queryClient,
latestRef,
removedRefKeys,
}: {
ref: GithubRef;
key: string;
queryClient: QueryClient;
latestRef: RefObject<ComposerGithubAutoAttachInput>;
removedRefKeys: Set<string>;
}): Promise<void> {
const snapshot = latestRef.current;
if (!snapshot.client || !snapshot.isConnected || !isRefStillPresent(ref, snapshot)) {
return;
}
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
if (!search) {
return;
}
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
return;
}
latestRef.current.setAttachments((current) => {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
return current;
}
return toggleGithubAttachment(current, item);
});
}
function refsReadyForLookup({
params,
removedRefKeys,
pendingRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
removedRefKeys: Set<string>;
pendingRefKeys: Set<string>;
}): GithubRef[] {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
return [];
}
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
const key = githubRefKey(ref);
return (
!removedRefKeys.has(key) &&
!pendingRefKeys.has(key) &&
!hasGithubAttachment(params.attachments, ref)
);
});
}
async function fetchGithubRefSearch({
ref,
snapshot,
queryClient,
}: {
ref: GithubRef;
snapshot: ComposerGithubAutoAttachInput;
queryClient: QueryClient;
}) {
if (!snapshot.client) {
return null;
}
try {
return await queryClient.fetchQuery(
buildGithubSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
enabled: true,
}),
);
} catch {
return null;
}
}
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
return extractGithubRefs(params.text, params.remoteUrl).some(
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
);
}
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
return ref.kind === "pull" ? "pr" : "issue";
}
function githubRefKey(ref: GithubRef): string {
return `${githubItemKind(ref)}:${ref.number}`;
}
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
) {
return null;
}
return `${attachment.item.kind}:${attachment.item.number}`;
}

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,18 +8,10 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import {
Dimensions,
Platform,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} 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 { ExternalLink } from "lucide-react-native";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
@@ -31,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;
@@ -92,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;
}
@@ -316,9 +311,62 @@ function WorkspaceHoverCardContent({
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
function getChecksSummaryCounts(checks: NonNullable<PrHint["checks"]>) {
return checks.reduce(
(counts, check) => {
if (check.status === "success") counts.passed += 1;
else if (check.status === "failure") counts.failed += 1;
else if (check.status !== "skipped" && check.status !== "cancelled") counts.pending += 1;
return counts;
},
{ passed: 0, failed: 0, pending: 0 },
);
}
function ChecksSummaryPill({
count,
kind,
}: {
count: number;
kind: "passed" | "failed" | "pending";
}) {
if (count === 0) return null;
if (kind === "passed") {
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleCheck size={12} uniProps={successColorMapping} />
<Text style={styles.checksStatusTextPassed}>{count}</Text>
</View>
);
}
if (kind === "failed") {
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleX size={12} uniProps={dangerColorMapping} />
<Text style={styles.checksStatusTextFailed}>{count}</Text>
</View>
);
}
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleDot size={12} uniProps={warningColorMapping} />
<Text style={styles.checksStatusTextPending}>{count}</Text>
</View>
);
}
function ChecksSummaryContent({
checks,
@@ -327,26 +375,7 @@ function ChecksSummaryContent({
checks: NonNullable<PrHint["checks"]>;
hovered: boolean;
}) {
const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length;
let badgeLabel: string;
let dotStyle: StyleProp<ViewStyle>;
let statusTextStyle: StyleProp<TextStyle>;
if (failed > 0) {
badgeLabel = `${failed} failed`;
dotStyle = styles.checksDotFailed;
statusTextStyle = styles.checksStatusTextFailed;
} else if (pending > 0) {
badgeLabel = `${pending} running`;
dotStyle = styles.checksDotPending;
statusTextStyle = styles.checksStatusTextPending;
} else {
badgeLabel = `${checks.length} passed`;
dotStyle = styles.checksDotPassed;
statusTextStyle = styles.checksStatusTextPassed;
}
const { passed, failed, pending } = getChecksSummaryCounts(checks);
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
@@ -360,8 +389,9 @@ function ChecksSummaryContent({
)}
<Text style={labelStyle}>Checks</Text>
<View style={styles.checksSummaryCounts}>
<View style={dotStyle} />
<Text style={statusTextStyle}>{badgeLabel}</Text>
<ChecksSummaryPill count={passed} kind="passed" />
<ChecksSummaryPill count={failed} kind="failed" />
<ChecksSummaryPill count={pending} kind="pending" />
</View>
</>
);
@@ -465,42 +495,29 @@ const styles = StyleSheet.create((theme) => ({
checksSummaryCounts: {
flexDirection: "row",
alignItems: "center",
gap: 4,
gap: theme.spacing[2],
flex: 1,
justifyContent: "flex-end",
},
checksDotFailed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.red[500],
},
checksDotPending: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.amber[500],
},
checksDotPassed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.palette.green[500],
checksSummaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
checksStatusTextFailed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
color: theme.colors.statusDanger,
},
checksStatusTextPending: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.amber[500],
color: theme.colors.statusWarning,
},
checksStatusTextPassed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[500],
color: theme.colors.statusSuccess,
},
}));

View File

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

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
});
});
});

View File

@@ -0,0 +1,85 @@
import type { PaseoConfigRaw } from "@server/shared/messages";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
interface ReadProjectConfigResult {
ok: boolean;
config?: PaseoConfigRaw | null;
}
export interface WorktreeSetupCalloutPolicy {
id: string;
dismissalKey: string;
priority: number;
title: string;
description: string;
actionLabel: string;
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
testID: string;
}
export function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorktreeSetupWorkspaceInput,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
}
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}

View File

@@ -1,279 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});

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