Compare commits

..

91 Commits

Author SHA1 Message Date
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
258 changed files with 20908 additions and 4804 deletions

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

View File

@@ -1,5 +1,74 @@
# Changelog
## 0.1.76 - 2026-05-15
### Added
- **Chat timestamps and turn durations.** Every message shows when it was sent, and each turn surfaces how long the agent took.
- **Auto Review permission mode for Claude Code and Codex.** Agents stop after each assistant turn for review instead of running unattended. ([#928](https://github.com/getpaseo/paseo/pull/928), [#963](https://github.com/getpaseo/paseo/pull/963) by [@bolasblack](https://github.com/bolasblack))
- Surface Codex's context compaction events and the `/compact` command in chat.
- Optional auto-archive for worktrees once their PR merges.
- Paste a GitHub PR or issue URL into the composer to attach it as context.
- Surface GitHub auto-merge actions in the PR hover card.
- Show all PR check counts in the PR hover card.
- Rename a project to disambiguate duplicates that share a folder name.
- Confirm before archiving a worktree with uncommitted or unpushed work.
- Claude Code now picks up models from `~/.claude/settings.json` so custom model lists show up in the model picker.
- Local Claude Code settings (`.claude/settings.local.json`) apply per workspace.
- Diagnostics for generic ACP providers surface in the model picker.
- MCP: control and discover provider features from external orchestrators. ([#909](https://github.com/getpaseo/paseo/pull/909), [#910](https://github.com/getpaseo/paseo/pull/910) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
### Improved
- Surface Claude error messages in chat instead of ending the turn silently.
- Ref picker auto-selects when a single PR is attached.
- New workspace flow honors the currently checked-out branch when branching off. ([#908](https://github.com/getpaseo/paseo/pull/908) by [@sbtobb](https://github.com/sbtobb))
- OpenCode models from console subscription providers now appear in the model picker. ([#917](https://github.com/getpaseo/paseo/pull/917) by [@t2o2](https://github.com/t2o2))
- Cursor model picker reflects the models advertised by the Cursor ACP client. ([#958](https://github.com/getpaseo/paseo/pull/958) by [@chrisbanes](https://github.com/chrisbanes))
### Fixed
- iPad hardware Enter submits the composer. ([#919](https://github.com/getpaseo/paseo/pull/919) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
- PR status falls back to a non-checks query for fine-grained GitHub tokens. ([#932](https://github.com/getpaseo/paseo/pull/932) by [@32r4](https://github.com/32r4))
- ACP errors display as readable text instead of `[object Object]`.
- OpenCode no longer hangs on retry when the upstream provider stalls.
- Worktree ahead count is correct when the upstream branch has been deleted.
- Branch-off worktrees track the correct upstream.
- File changes view works on empty repositories with no commits yet.
- Assistant message file links open the correct file.
- Default thinking option matches the selected model's capabilities.
- Shift+Enter works again in terminal input modes.
- Duplicate project entries no longer appear after reopening a project.
- Pi-backed sessions recover after a Copilot 413 instead of staying stuck.
- Skip probing unrelated executable candidates when launching agents.
- Relay E2EE reconnects cleanly under racing connect/disconnect.
- Workspace kind stays in sync with project kind after reconfiguration.
- zsh integration files install with usable runtime modes.
- MCP worktree cache refreshes after create and archive. ([#911](https://github.com/getpaseo/paseo/pull/911) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
## 0.1.75 - 2026-05-12
### Added
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
### Fixed
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
- Fixed Copilot's **Allow All** mode (renamed from Autopilot) ([#935](https://github.com/getpaseo/paseo/pull/935))
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
- iPhone HEIC photos now attach correctly from the image picker ([#934](https://github.com/getpaseo/paseo/pull/934))
- Scheduled agents now archive automatically after each run ([#945](https://github.com/getpaseo/paseo/pull/945))
- Windows: Codex command summaries trim `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed

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

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

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

@@ -18,6 +18,16 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`
---
## 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
### 1. Create the provider class

View File

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

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

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

@@ -0,0 +1,157 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
# different value.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
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 npmDepsHash;
# 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\"`.";
};
};
@@ -111,9 +152,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,6 +213,10 @@ 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;
@@ -149,6 +235,9 @@ in
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 +261,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-1NkvtCT9TelnukukDD+jkIbwxLbWiNeMbXtJIzhTzv0=

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

567
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.73",
"version": "0.1.76",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

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,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,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.73",
"version": "0.1.76",
"private": true,
"main": "index.ts",
"scripts": {
@@ -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

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

@@ -27,6 +27,7 @@ import {
ShieldAlert,
ShieldCheck,
ShieldOff,
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
@@ -180,6 +181,7 @@ const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
ShieldQuestionMark,
} as const;
function alwaysTrue() {

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,287 @@
import React, { memo, useCallback, useEffect, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
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;
}) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
{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"],
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
transform: [{ translateY: 1 }],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.foregroundMuted,
},
}));

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,177 @@ 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 { 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 {
@@ -118,6 +245,8 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -259,21 +388,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
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 +412,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 +506,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 +629,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 +644,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 +668,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 +699,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 +762,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 +791,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 +878,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 +1174,7 @@ const stylesheet = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
listContentContainer: {
paddingVertical: 0,
@@ -1212,6 +1195,7 @@ const stylesheet = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
emptyState: {
flex: 1,
@@ -1225,51 +1209,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

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

@@ -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,9 @@ 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";
type QueuedMessage = QueuedComposerMessage;
@@ -137,6 +139,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 +180,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,
@@ -890,6 +881,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);
@@ -1135,6 +1137,7 @@ export function Composer({
const handleRemoveAttachment = useCallback(
(index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
@@ -1146,7 +1149,7 @@ export function Composer({
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
@@ -1376,16 +1379,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 +1423,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

@@ -791,10 +791,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)],
[],

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

@@ -47,6 +47,7 @@ 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";
@@ -1598,6 +1599,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
defaultSendBehavior,
isAgentRunning,
});
useIosHardwareKeyboardSubmit({
isEnabled: isInputFocused && !isSendButtonDisabled,
onSubmit: handleDefaultSendAction,
});
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
submitButtonAccessibilityLabel,
canPressLoadingButton,

View File

@@ -49,7 +49,7 @@ import {
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -68,14 +68,13 @@ import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
parseAssistantFileLink,
parseInlinePathToken,
type InlinePathTarget,
} from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { type InlinePathTarget, parseInlinePathToken } from "@/utils/inline-path";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
import type { ToastApi } from "@/components/toast-host";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
@@ -92,6 +91,7 @@ import {
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -187,6 +187,10 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
`;
let webToolCallShimmerRegistered = false;
const SCROLL_EDGE_EPSILON = 0.5;
// Font size for stream metadata (timestamps, durations, live elapsed timer).
// Lives between theme.fontSize.xs (12) and theme.fontSize.sm (14); no token.
export const STREAM_METADATA_FONT_SIZE = 13;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
@@ -318,7 +322,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[2],
...(isWeb ? { userSelect: "text" as const } : {}),
},
content: {
@@ -392,16 +395,26 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
copyButton: {
alignSelf: "flex-end",
padding: theme.spacing[1],
marginRight: -theme.spacing[1],
},
trailingRow: {
alignSelf: "flex-end",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
copyButtonHidden: {
trailingRowHidden: {
opacity: 0,
},
copyButtonVisible: {
trailingRowVisible: {
opacity: 1,
},
timestampText: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
}));
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
@@ -434,22 +447,25 @@ export const UserMessage = memo(function UserMessage({
message,
images = [],
attachments = [],
timestamp: _timestamp,
timestamp,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
);
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
const handleHoverOut = useCallback(() => setMessageHovered(false), []);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
const containerStyle = useMemo(
@@ -477,22 +493,22 @@ export const UserMessage = memo(function UserMessage({
],
[hasText],
);
const copyButtonStyle = useMemo(
const trailingRowStyle = useMemo(
() => [
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
userMessageStylesheet.trailingRow,
showTrailingRow
? userMessageStylesheet.trailingRowVisible
: userMessageStylesheet.trailingRowHidden,
],
[showCopyButton],
[showTrailingRow],
);
return (
<View style={containerStyle}>
<Pressable
<View
style={userMessageStylesheet.content}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -525,18 +541,171 @@ export const UserMessage = memo(function UserMessage({
) : null}
</View>
{hasText ? (
<TurnCopyButton
getContent={getMessageContent}
containerStyle={copyButtonStyle}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}
accessibilityLabel="Copy message"
/>
</View>
) : null}
</Pressable>
</View>
</View>
);
});
interface AssistantTurnFooterProps {
getContent: () => string;
completedAt?: Date;
durationMs?: number;
}
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
copyButton: {
alignSelf: "center",
padding: theme.spacing[1],
paddingTop: theme.spacing[1],
marginTop: 0,
marginLeft: -theme.spacing[1],
},
labelWrapper: {
position: "relative",
},
labelSizer: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
opacity: 0,
},
labelOverlay: {
position: "absolute",
top: 0,
left: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
}));
const TIMESTAMP_REVEAL_MS = 3000;
/**
* Footer rendered next to the copy button at the end of an assistant turn.
* Always shows the turn duration; swaps to the end timestamp on hover (web)
* or tap (native). The hidden sizer keeps the label width stable while the
* visible text swaps.
*/
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent,
completedAt,
durationMs,
}: AssistantTurnFooterProps) {
const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false);
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (revealTimerRef.current) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
}
};
}, []);
const durationLabel = useMemo(
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
[durationMs],
);
const timestampLabel = useMemo(
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
[completedAt],
);
const canSwap = Boolean(timestampLabel);
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const handlePress = useCallback(() => {
if (isWeb || !canSwap) return;
if (revealTimerRef.current) {
clearTimeout(revealTimerRef.current);
}
setPressedReveal((prev) => !prev);
revealTimerRef.current = setTimeout(() => {
setPressedReveal(false);
revealTimerRef.current = null;
}, TIMESTAMP_REVEAL_MS);
}, [canSwap]);
return (
<View style={assistantTurnFooterStylesheet.container}>
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{durationLabel ? (
<Pressable
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : durationLabel}
</Text>
</View>
</Pressable>
) : null}
</View>
);
});
interface LiveElapsedProps {
startedAt: Date;
style?: StyleProp<TextStyle>;
testID?: string;
}
/**
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
* only this component re-renders on each tick.
*/
export const LiveElapsed = memo(function LiveElapsed({
startedAt,
style,
testID,
}: LiveElapsedProps) {
const startedAtMs = startedAt.getTime();
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
useEffect(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
const handle = setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
}, 100);
return () => clearInterval(handle);
}, [startedAtMs]);
return (
<Text style={style} testID={testID}>
{formatDuration(elapsedMs)}
</Text>
);
});
interface AssistantMessageProps {
message: string;
timestamp: number;
@@ -544,13 +713,12 @@ interface AssistantMessageProps {
workspaceRoot?: string;
serverId?: string;
client?: DaemonClient | null;
disableOuterSpacing?: boolean;
toast?: ToastApi | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
...(isWeb ? { userSelect: "text" as const } : {}),
},
@@ -560,24 +728,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
containerCompactBottom: {
paddingBottom: 0,
},
containerSpacing: {
marginBottom: theme.spacing[4],
},
// Used in custom markdownRules for path chip styling
pathChip: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
marginRight: theme.spacing[1],
marginVertical: 2,
},
pathChipText: {
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
userSelect: isWeb ? "text" : "auto",
},
imageFrame: {
width: "100%",
minHeight: 160,
@@ -851,44 +1001,27 @@ function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown):
return "Unable to load image preview.";
}
interface InlinePathChipProps {
content: string;
parsed: InlinePathTarget;
onPress: (target: InlinePathTarget) => void;
}
const INLINE_PATH_CHIP_STYLE = [
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
];
function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) {
const handlePress = useCallback(() => onPress(parsed), [onPress, parsed]);
return (
<Text
onPress={handlePress}
selectable={isWeb ? undefined : false}
style={INLINE_PATH_CHIP_STYLE}
>
{content}
</Text>
);
}
function MarkdownLink({
href,
source,
style,
onPress,
onPrefetch,
children,
}: {
href: string;
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
onPress: (url: string) => void;
onPress: (source: AssistantFileLinkSource) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
const handlePress = useCallback(() => onPress(href), [onPress, href]);
const handleHoverIn = useCallback(() => setHovered(true), []);
const href = source.href;
const handlePress = useCallback(() => onPress(source), [onPress, source]);
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
const handleHoverIn = useCallback(() => {
setHovered(true);
handlePrefetch();
}, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
@@ -912,6 +1045,7 @@ function MarkdownLink({
<Pressable
accessibilityRole="link"
onPress={handlePress}
onFocus={handlePrefetch}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
@@ -940,11 +1074,13 @@ function getInlineCodeAutoLinkUrl(
return null;
}
const matches: Array<{
index: number;
lastIndex: number;
url: string;
}> | null = markdownParser.linkify.match(trimmed);
const matches:
| {
index: number;
lastIndex: number;
url: string;
}[]
| null = markdownParser.linkify.match(trimmed);
if (!matches || matches.length !== 1) {
return null;
}
@@ -957,6 +1093,40 @@ function getInlineCodeAutoLinkUrl(
return match.url;
}
function getInlineCodeAutoLinkSource(input: {
href: string;
content: string;
}): AssistantFileLinkSource {
return {
href: input.href,
text: input.content,
markup: "linkify",
sourceInfo: "auto",
};
}
interface AssistantMarkdownAstNode extends ASTNode {
sourceInfo?: string;
}
function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLinkSource {
return {
href: typeof node.attributes?.href === "string" ? node.attributes.href : "",
text: getMarkdownNodeText(node),
markup: node.markup,
sourceInfo: node.sourceInfo,
sourceType: node.sourceType === "inline-code" ? "inline-code" : undefined,
};
}
function getMarkdownNodeText(node: ASTNode): string {
if (!node.children.length) {
return node.content ?? "";
}
return node.children.map(getMarkdownNodeText).join("");
}
function nodeHasParentType(parent: unknown, type: string): boolean {
if (Array.isArray(parent)) {
return parent.some((entry) => entry?.type === type);
@@ -990,7 +1160,6 @@ interface TurnCopyButtonProps {
containerStyle?: StyleProp<ViewStyle>;
accessibilityLabel?: string;
copiedAccessibilityLabel?: string;
onHoverChange?: (hovered: boolean) => void;
}
export const TurnCopyButton = memo(function TurnCopyButton({
@@ -998,7 +1167,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
containerStyle,
accessibilityLabel,
copiedAccessibilityLabel,
onHoverChange,
}: TurnCopyButtonProps) {
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1030,8 +1198,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
};
}, []);
const handleHoverIn = useCallback(() => onHoverChange?.(true), [onHoverChange]);
const handleHoverOut = useCallback(() => onHoverChange?.(false), [onHoverChange]);
const pressableStyle = useMemo(
() => [turnCopyButtonStylesheet.container, containerStyle],
[containerStyle],
@@ -1040,8 +1206,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
<Pressable
onPress={handleCopy}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={
@@ -1053,9 +1217,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
? turnCopyButtonStylesheet.iconHoveredColor.color
: turnCopyButtonStylesheet.iconColor.color;
return copied ? (
<Check size={18} color={iconColor} />
<Check size={16} color={iconColor} />
) : (
<Copy size={18} color={iconColor} />
<Copy size={16} color={iconColor} />
);
}}
</Pressable>
@@ -1064,7 +1228,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: -6,
marginHorizontal: -13,
},
containerSpacing: {
marginBottom: theme.spacing[1],
@@ -1135,8 +1299,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
flex: 1,
},
chevron: {
marginLeft: theme.spacing[1],
flexShrink: 0,
transform: [{ scale: 1.3 }],
},
openFileButton: {
marginLeft: theme.spacing[1],
@@ -1149,7 +1313,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
height: 14,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
transform: [{ scale: 1.3 }, { rotate: "90deg" }],
},
detailWrapper: {
borderBottomLeftRadius: theme.borderRadius.lg,
@@ -1393,20 +1557,22 @@ function MarkdownInheritedText({
}
interface MarkdownInheritedCodeLinkProps {
href: string;
source: AssistantFileLinkSource;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (url: string) => boolean;
onPress: (source: AssistantFileLinkSource) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
children: ReactNode;
}
function MarkdownInheritedCodeLink({
href,
source,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
children,
}: MarkdownInheritedCodeLinkProps) {
const style = useMemo(
@@ -1414,12 +1580,52 @@ function MarkdownInheritedCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<MarkdownLink href={href} style={style} onPress={onPress}>
<MarkdownLink source={source} style={style} onPress={onPress} onPrefetch={onPrefetch}>
{children}
</MarkdownLink>
);
}
interface MarkdownInlinePathCodeLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
}
function MarkdownInlinePathCodeLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
}: MarkdownInlinePathCodeLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<MarkdownInheritedCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
onPress={onPress}
onPrefetch={onPrefetch}
>
{content}
</MarkdownInheritedCodeLink>
);
}
interface MarkdownListItemContentProps {
contentStyle: ViewStyle;
children: ReactNode;
@@ -1444,6 +1650,42 @@ function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphVi
return <View style={style}>{children}</View>;
}
// List spacing in markdown:
// - p -> list and list -> p use a slightly larger gap than p -> p, so lists
// read as their own section against surrounding prose.
// - list -> list keeps the normal p-to-p gap; back-to-back lists are
// continuous content, not section breaks.
//
// Paragraph's marginBottom is SPACING[3] = 12 (and marginTop is 0). To produce
// 16px gaps on p<->list transitions and 12px on list<->list, we add a constant
// marginTop on lists (4) and switch marginBottom by next-sibling type:
// p -> list = p.marginBottom(12) + list.marginTop(4) = 16
// list -> p = list.marginBottom(16) + p.marginTop(0) = 16
// list -> list = list.marginBottom(8) + list.marginTop(4) = 12
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; // 4
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; // 16
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; // 8
function getMarkdownListContextMarginBottom(node: ASTNode, parent: ASTNode[]): number {
const nextType = getMarkdownNextSiblingType(node, parent);
const nextIsList = nextType === "bullet_list" || nextType === "ordered_list";
return nextIsList ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE;
}
interface MarkdownListViewProps {
baseStyle: ViewStyle;
marginBottom: number;
children: ReactNode;
}
function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) {
const style = useMemo(
() => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }],
[baseStyle, marginBottom],
);
return <View style={style}>{children}</View>;
}
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp: _timestamp,
@@ -1451,13 +1693,9 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
serverId,
client,
disableOuterSpacing,
toast,
spacing = "default",
}: AssistantMessageProps) {
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default",
);
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
@@ -1471,20 +1709,34 @@ export const AssistantMessage = memo(function AssistantMessage({
return parser;
}, []);
const handleLinkPress = useCallback(
(url: string) => {
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
return false;
}
const fileLinkResolver = useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile: onInlinePathPress,
toast,
});
void openExternalUrl(url);
const handleLinkPress = useCallback(
(source: AssistantFileLinkSource) => {
fileLinkResolver.open({ source });
},
[fileLinkResolver],
);
const handleLinkPrefetch = useCallback(
(source: AssistantFileLinkSource) => {
fileLinkResolver.prefetch({ source });
},
[fileLinkResolver],
);
const handleMarkdownLinkPress = useCallback(
(url: string) => {
fileLinkResolver.open({ source: { href: url } });
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
},
[onInlinePathPress, workspaceRoot],
[fileLinkResolver],
);
const markdownRules = useMemo<RenderRules>(() => {
@@ -1558,30 +1810,38 @@ export const AssistantMessage = memo(function AssistantMessage({
) => {
const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link");
const parsed =
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
const shouldResolveInlinePath =
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content);
if (parsed && onInlinePathPress) {
if (shouldResolveInlinePath) {
return (
<InlinePathChip
<MarkdownInlinePathCodeLink
key={node.key}
content={content}
parsed={parsed}
onPress={onInlinePathPress}
inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline}
linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
/>
);
}
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
if (inlineCodeLinkUrl) {
const source = getInlineCodeAutoLinkSource({
href: inlineCodeLinkUrl,
content,
});
return (
<MarkdownInheritedCodeLink
key={node.key}
href={inlineCodeLinkUrl}
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline}
linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
>
{content}
</MarkdownInheritedCodeLink>
@@ -1601,22 +1861,30 @@ export const AssistantMessage = memo(function AssistantMessage({
bullet_list: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles.bullet_list}>
<MarkdownListView
key={node.key}
baseStyle={styles.bullet_list}
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
>
{children}
</View>
</MarkdownListView>
),
ordered_list: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
parent: ASTNode[],
styles: MarkdownStyles,
) => (
<View key={node.key} style={styles.ordered_list}>
<MarkdownListView
key={node.key}
baseStyle={styles.ordered_list}
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
>
{children}
</View>
</MarkdownListView>
),
list_item: (
node: ASTNode,
@@ -1650,9 +1918,10 @@ export const AssistantMessage = memo(function AssistantMessage({
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
<MarkdownLink
key={node.key}
href={typeof node.attributes?.href === "string" ? node.attributes.href : ""}
source={getMarkdownLinkSource(node)}
style={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
>
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;
@@ -1691,7 +1960,15 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
}, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
}, [
client,
handleLinkPrefetch,
handleLinkPress,
markdownParser,
onInlinePathPress,
serverId,
workspaceRoot,
]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
@@ -1706,9 +1983,8 @@ export const AssistantMessage = memo(function AssistantMessage({
assistantMessageStylesheet.containerCompactTop,
(spacing === "compactBottom" || spacing === "compactBoth") &&
assistantMessageStylesheet.containerCompactBottom,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
],
[spacing, resolvedDisableOuterSpacing],
[spacing],
);
return (
@@ -1723,7 +1999,7 @@ export const AssistantMessage = memo(function AssistantMessage({
text={block}
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleLinkPress}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
))}
@@ -1739,7 +2015,6 @@ interface SpeakMessageProps {
const speakMessageStylesheet = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
},
containerSpacing: {
@@ -1804,7 +2079,6 @@ interface ActivityLogProps {
const activityLogStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
overflow: "hidden",
},
@@ -1978,6 +2252,7 @@ export const ActivityLog = memo(function ActivityLog({
interface CompactionMarkerProps {
status: "loading" | "completed";
trigger?: "auto" | "manual";
preTokens?: number;
}
@@ -2008,12 +2283,10 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
export const CompactionMarker = memo(function CompactionMarker({
status,
trigger,
preTokens,
}: CompactionMarkerProps) {
let label: string;
if (status === "loading") label = "Compacting...";
else if (preTokens) label = `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
else label = "Context compacted";
const label = getCompactionMarkerLabel({ status, trigger, preTokens });
return (
<View style={compactionStylesheet.container}>
@@ -2340,6 +2613,26 @@ function ExpandableBadgeLabelRow({
);
}
// HACK: lucide ships every icon inside a 24×24 viewBox where the path
// doesn't touch the edges — there's per-icon internal padding. The layout
// already places the SVG element's box on the rail, but the visible glyph
// inside the SVG sits inset by a few pixels (and the inset amount differs
// per icon — chevron-right paints only in the right half of its viewBox,
// regular tool icons paint roughly the full viewBox minus ~1 unit margin).
//
// Lucide has no viewBox knob, so the only way to nudge the visible glyph
// flush with the rail is a per-icon negative margin. Cosmetic; not exact —
// every lucide icon has slightly different padding and we're not measuring
// each one. Two buckets is the compromise:
// - LUCIDE_TOOL_ICON_NUDGE_LEFT: regular tool icons (path mostly fills
// the viewBox); needs ~1px left shift.
// - LUCIDE_CHEVRON_NUDGE_LEFT: chevron-right (path in right half of
// viewBox, and we scale it 1.3×); needs ~4px left shift.
// If we ever want this exact, the principled fix is a custom <Svg> wrapper
// with a tight viewBox per icon — see option (2) in the design discussion.
const LUCIDE_TOOL_ICON_NUDGE_LEFT: ViewStyle = { marginLeft: -1 };
const LUCIDE_CHEVRON_NUDGE_LEFT: ViewStyle = { marginLeft: -4 };
function renderExpandableBadgeIcon({
isError,
isActive,
@@ -2350,14 +2643,20 @@ function renderExpandableBadgeIcon({
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
}): ReactNode {
if (isError) {
return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
return (
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
<ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />
</View>
);
}
if (ThemedIcon) {
return (
<ThemedIcon
size={12}
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
/>
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
<ThemedIcon
size={12}
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
/>
</View>
);
}
return null;
@@ -2374,7 +2673,7 @@ function renderExpandableBadgeIconSlot({
}): ReactNode {
if (showChevron) {
return (
<ThemedChevronRightIcon size={14} style={chevronStyle} uniProps={foregroundColorMapping} />
<ThemedChevronRightIcon size={12} style={chevronStyle} uniProps={foregroundColorMapping} />
);
}
return iconNode;
@@ -2499,7 +2798,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
const [isIconHovered, setIsIconHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
@@ -2527,8 +2825,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
const handleIconHoverIn = useCallback(() => setIsIconHovered(true), []);
const handleIconHoverOut = useCallback(() => setIsIconHovered(false), []);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
@@ -2718,6 +3014,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
() => [
expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded,
LUCIDE_CHEVRON_NUDGE_LEFT,
],
[isExpanded],
);
@@ -2725,7 +3022,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered && !isIconHovered,
showChevron: isInteractive && isHovered,
chevronStyle,
iconNode,
});
@@ -2753,13 +3050,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View
style={expandableBadgeStylesheet.iconBadge}
onPointerEnter={isWeb ? handleIconHoverIn : undefined}
onPointerLeave={isWeb ? handleIconHoverOut : undefined}
>
{iconSlotNode}
</View>
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
<ExpandableBadgeLabelRow
label={label}
labelStyle={labelStyle}

View File

@@ -106,6 +106,7 @@ import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
@@ -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();
@@ -1474,92 +1478,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 +1594,7 @@ function WorkspaceRowWithMenu({
priority: 0,
handle: () => {
if (isWorktree) {
handleArchiveWorktree();
void archiveWorktreeAfterConfirmation();
} else {
handleArchiveWorkspace();
}

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";
@@ -129,6 +130,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;
@@ -194,6 +196,7 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
@@ -219,12 +222,14 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
});
mountCallbacksRef.current = {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
@@ -444,10 +449,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";
@@ -188,6 +189,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 +200,10 @@ export function TerminalPane({
useEffect(() => {
terminalIdRef.current = terminalId;
inputModeRef.current = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
}, [terminalId]);
const requestTerminalFocus = useCallback(() => {
@@ -413,7 +422,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 +606,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] }));
@@ -674,6 +689,7 @@ export function TerminalPane({
onInput={handleTerminalData}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}

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

@@ -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, Platform, 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";
@@ -316,9 +308,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 +372,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 +386,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 +492,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

@@ -14,7 +14,9 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
//
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
// For hover-tracking, see docs/hover.md — the short answer is `onPointerEnter`/
// `onPointerLeave` on a plain `View`, with any press behavior on a separate
// inner `Pressable`. No platform gate needed.
// ---------------------------------------------------------------------------
/** Browser or Electron — the JS runtime has access to the DOM. */

View File

@@ -97,6 +97,13 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
[theme.colors.foreground, toast],
);
const handlePrimaryPress = useCallback(() => {
if (!gitActions.primary) {
return;
}
handleActionSelect(gitActions.primary);
}, [gitActions.primary, handleActionSelect]);
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
const primaryDisabled = gitActions.primary?.disabled;
@@ -124,7 +131,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<Pressable
testID="changes-primary-cta"
style={primaryPressableStyle}
onPress={gitActions.primary.handler}
onPress={handlePrimaryPress}
disabled={gitActions.primary.disabled}
accessibilityRole="button"
accessibilityLabel={gitActions.primary.label}

View File

@@ -170,6 +170,91 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
});
await useCheckoutGitActionsStore
.getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
enabled: false,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: {},
});
await expect(
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
).toBe("idle");
});
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {

View File

@@ -32,6 +32,10 @@ export type CheckoutGitAsyncActionId =
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "enable-pr-auto-merge-squash"
| "enable-pr-auto-merge-merge"
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
@@ -52,6 +56,13 @@ function resolveClient(serverId: string) {
return client;
}
function assertGitHubAutoMergeActionsSupported(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
throw new Error("Update the host to use GitHub auto-merge actions.");
}
}
function setStatus(
key: CheckoutKey,
actionId: CheckoutGitAsyncActionId,
@@ -231,6 +242,12 @@ interface CheckoutGitActionsStoreState {
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
enablePrAutoMerge: (params: {
serverId: string;
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
@@ -392,6 +409,38 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
assertGitHubAutoMergeActionsSupported(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: `enable-pr-auto-merge-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
disablePrAutoMerge: async ({ serverId, cwd }) => {
assertGitHubAutoMergeActionsSupported(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: "disable-pr-auto-merge",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,

View File

@@ -1,17 +1,43 @@
import { describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@server/shared/messages";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
function githubStatus(
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
return {
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
githubAutoMergeActionsEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
pullRequestState: null,
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: null,
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -65,6 +91,26 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"enable-pr-auto-merge-squash": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"enable-pr-auto-merge-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"enable-pr-auto-merge-rebase": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"disable-pr-auto-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
@@ -167,6 +213,9 @@ describe("git-actions-policy", () => {
behindBaseCount: 1,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
@@ -177,9 +226,6 @@ describe("git-actions-policy", () => {
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
@@ -246,6 +292,28 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
});
it("uses GitHub merge state, not mergeable, for direct merge readiness", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
shipDefault: "pr",
}),
);
@@ -266,6 +334,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
@@ -278,6 +347,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "merge",
}),
);
@@ -305,6 +375,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
@@ -321,6 +392,73 @@ describe("git-actions-policy", () => {
]);
});
it("keeps the visible PR action model stable on feature branches", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
const pullRequestActions = actions.secondary
.filter((action) =>
["pr", "merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
)
.map((action) => ({
id: action.id,
label: action.label,
pendingLabel: action.pendingLabel,
successLabel: action.successLabel,
disabled: action.disabled,
unavailableMessage: action.unavailableMessage,
startsGroup: action.startsGroup,
}));
expect(pullRequestActions).toEqual([
{
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
{
id: "merge-pr-squash",
label: "Squash and merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: true,
},
{
id: "merge-pr-merge",
label: "Create a merge commit",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
{
id: "merge-pr-rebase",
label: "Rebase and merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
unavailableMessage: undefined,
startsGroup: false,
},
]);
});
it("uses Merge locally for the local merge action", () => {
const actions = buildGitActions(
createInput({
@@ -334,27 +472,11 @@ describe("git-actions-policy", () => {
});
it.each([
[
"draft",
{ pullRequestIsDraft: true },
"Merge PR isn't available because the pull request is still a draft",
],
[
"merged",
{ pullRequestIsMerged: true },
"Merge PR isn't available because the pull request is already merged",
],
[
"closed",
{ pullRequestState: "closed" as const },
"Merge PR isn't available because the pull request is closed",
],
[
"conflicting",
{ pullRequestMergeable: "CONFLICTING" as const },
"Merge PR isn't available because the pull request has conflicts",
],
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
["draft", { pullRequestIsDraft: true }],
["merged", { pullRequestIsMerged: true }],
["closed", { pullRequestState: "closed" as const }],
["conflicting", { pullRequestMergeable: "CONFLICTING" as const }],
])("does not offer direct merge actions when the PR is %s", (_name, overrides) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
@@ -364,6 +486,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
...overrides,
}),
);
@@ -371,13 +494,224 @@ describe("git-actions-policy", () => {
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
);
expect(mergePrActions).toHaveLength(3);
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
message,
message,
message,
expect(mergePrActions).toEqual([]);
expect(actions.secondary).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "pr", label: "View PR" })]),
);
});
it("preserves legacy direct merge actions when old payloads have no GitHub facts", () => {
const oldDaemonStatus = CheckoutPrStatusSchema.parse({
number: 456,
url: "https://example.com/pr/456",
title: "Legacy payload",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
mergeable: "MERGEABLE",
});
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: oldDaemonStatus.url,
pullRequestState: "open",
pullRequestIsDraft: oldDaemonStatus.isDraft,
pullRequestIsMerged: oldDaemonStatus.isMerged,
pullRequestMergeable: oldDaemonStatus.mergeable,
pullRequestGithub: oldDaemonStatus.github,
shipDefault: "pr",
}),
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Squash and merge" });
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
});
it("requires GitHub's direct-merge allowlist before promoting PR merge", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/993",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "enable-pr-auto-merge-squash",
label: "Enable auto-merge with squash",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"enable-pr-auto-merge-squash",
]);
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it("does not offer auto-merge when the daemon feature gate is missing", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/993",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
}),
githubAutoMergeActionsEnabled: false,
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
false,
);
});
it("shows existing auto-merge as state and disables it when the viewer can", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
enabledBy: "octocat",
},
viewerCanDisableAutoMerge: true,
}),
}),
);
expect(actions.secondary).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "disable-pr-auto-merge",
label: "Auto-merge enabled",
disabled: false,
unavailableMessage: undefined,
}),
]),
);
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it("respects repository merge method policy for direct merge actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-merge",
label: "Create a merge commit",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-merge",
]);
});
it("does not treat merge queue repositories as direct mergeable", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "CLEAN",
isMergeQueueEnabled: true,
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
),
).toBe(false);
});
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
@@ -390,6 +724,7 @@ describe("git-actions-policy", () => {
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
isPaseoOwnedWorktree: true,
}),
);

View File

@@ -1,7 +1,11 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
import type {
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
PullRequestMergeable,
} from "@server/shared/messages";
export type GitActionId =
| "commit"
@@ -12,6 +16,10 @@ export type GitActionId =
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "enable-pr-auto-merge-squash"
| "enable-pr-auto-merge-merge"
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
@@ -46,12 +54,14 @@ interface GitActionRuntimeState {
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
githubAutoMergeActionsEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
pullRequestState: "open" | "closed" | null;
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
pullRequestGithub: PullRequestGithubStatus | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
@@ -67,24 +77,117 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const FEATURE_ACTION_IDS: GitActionId[] = [
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
type PullRequestActionId = Extract<
GitActionId,
| "pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "enable-pr-auto-merge-squash"
| "enable-pr-auto-merge-merge"
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
>;
type PullRequestDirectMergeActionId = Extract<
GitActionId,
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase"
>;
type PullRequestAutoMergeEnableActionId = Extract<
GitActionId,
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
>;
type PullRequestActionRole = "status" | "direct" | "auto";
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
interface PullRequestActionModel {
readonly id: PullRequestActionId;
readonly role: PullRequestActionRole;
readonly build: (input: BuildGitActionsInput) => GitAction;
}
interface PullRequestDirectMergeActionModel {
readonly id: PullRequestDirectMergeActionId;
readonly role: "direct";
readonly label: string;
readonly method: CheckoutPrMergeMethod;
readonly startsGroup: boolean;
}
interface PullRequestAutoMergeEnableActionModel {
readonly id: PullRequestAutoMergeEnableActionId;
readonly role: "auto";
readonly label: string;
readonly method: CheckoutPrMergeMethod;
readonly startsGroup: boolean;
}
const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [
{
id: "merge-pr-squash",
role: "direct",
label: "Squash and merge",
method: "squash",
startsGroup: true,
},
{
id: "merge-pr-merge",
role: "direct",
label: "Create a merge commit",
method: "merge",
startsGroup: false,
},
{
id: "merge-pr-rebase",
role: "direct",
label: "Rebase and merge",
method: "rebase",
startsGroup: false,
},
] as const satisfies readonly PullRequestDirectMergeActionModel[];
const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [
{
id: "enable-pr-auto-merge-squash",
role: "auto",
label: "Enable auto-merge with squash",
method: "squash",
startsGroup: true,
},
{
id: "enable-pr-auto-merge-merge",
role: "auto",
label: "Enable auto-merge with merge commit",
method: "merge",
startsGroup: false,
},
{
id: "enable-pr-auto-merge-rebase",
role: "auto",
label: "Enable auto-merge with rebase",
method: "rebase",
startsGroup: false,
},
] as const satisfies readonly PullRequestAutoMergeEnableActionModel[];
const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
{ id: "pr", role: "status", build: buildPrAction },
...PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.map((model) => ({
...model,
build: (input: BuildGitActionsInput) => buildDirectPullRequestMergeAction(input, model),
})),
...PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.map((model) => ({
...model,
build: (input: BuildGitActionsInput) => buildEnablePullRequestAutoMergeAction(input, model),
})),
{
id: "disable-pr-auto-merge",
role: "auto",
build: buildDisablePullRequestAutoMergeAction,
},
];
const MERGE_PR_METHODS: Record<
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
{ label: string; method: CheckoutPrMergeMethod }
> = {
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
};
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
@@ -152,11 +255,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
for (const model of PULL_REQUEST_ACTION_MODELS) {
allActions.set(model.id, model.build(input));
}
allActions.set("merge-branch", {
id: "merge-branch",
@@ -209,7 +310,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
const secondaryIds = [...REMOTE_ACTION_IDS];
if (!input.isOnBaseBranch) {
secondaryIds.push(...FEATURE_ACTION_IDS);
secondaryIds.push(...getFeatureActionIds(input));
}
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
@@ -239,7 +340,10 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
return "merge-from-base";
}
if (canMergePr(input) && input.shipDefault === "pr") {
return "merge-pr-squash";
return getDefaultDirectPullRequestMergeActionId(input);
}
if (canEnablePrAutoMerge(input) && input.shipDefault === "pr") {
return getDefaultEnablePullRequestAutoMergeActionId(input);
}
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
return "merge-branch";
@@ -253,6 +357,41 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
return null;
}
function getPullRequestActionIds(filter: {
roles: readonly PullRequestActionRole[];
input: BuildGitActionsInput;
}): PullRequestActionId[] {
return PULL_REQUEST_ACTION_MODELS.filter((model) => filter.roles.includes(model.role))
.filter((model) => shouldShowPullRequestAction(filter.input, model.id))
.map((model) => model.id);
}
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
return [
"merge-from-base",
"merge-branch",
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
];
}
function getDefaultDirectPullRequestMergeActionId(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionId {
return (
getPreferredDirectPullRequestMergeActionModel(input)?.id ??
PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS[0].id
);
}
function getDefaultEnablePullRequestAutoMergeActionId(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionId {
return (
getPreferredEnablePullRequestAutoMergeActionModel(input)?.id ??
PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS[0].id
);
}
function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
@@ -288,23 +427,60 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
};
}
function buildMergePrAction(
function buildDirectPullRequestMergeAction(
input: BuildGitActionsInput,
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
model: PullRequestDirectMergeActionModel,
): GitAction {
const runtime = input.runtime[id];
const config = MERGE_PR_METHODS[id];
const runtime = input.runtime[model.id];
const unavailableMessage = getMergePrUnavailableMessage(input);
return {
id,
label: config.label,
id: model.id,
label: model.label,
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: runtime.disabled || shouldDisableMergePrAction(input),
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
startsGroup: id === "merge-pr-squash",
startsGroup: model.startsGroup,
handler: runtime.handler,
};
}
function buildEnablePullRequestAutoMergeAction(
input: BuildGitActionsInput,
model: PullRequestAutoMergeEnableActionModel,
): GitAction {
const runtime = input.runtime[model.id];
return {
id: model.id,
label: model.label,
pendingLabel: "Enabling auto-merge...",
successLabel: "Auto-merge enabled",
disabled: runtime.disabled,
status: runtime.status,
icon: runtime.icon,
startsGroup: model.startsGroup,
handler: runtime.handler,
};
}
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
const runtime = input.runtime["disable-pr-auto-merge"];
const unavailableMessage =
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
? undefined
: "Auto-merge is enabled, but this account can't disable it";
return {
id: "disable-pr-auto-merge",
label: "Auto-merge enabled",
pendingLabel: "Disabling auto-merge...",
successLabel: "Auto-merge disabled",
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
startsGroup: true,
handler: runtime.handler,
};
}
@@ -327,18 +503,55 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
}
function canMergePr(input: BuildGitActionsInput): boolean {
return (
const github = input.pullRequestGithub;
const canMergeFromTopLevelStatus =
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestState === "open" &&
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable === "MERGEABLE" &&
input.pullRequestMergeable !== "CONFLICTING" &&
input.aheadCount > 0 &&
!input.hasUncommittedChanges &&
input.behindOfOrigin === 0 &&
input.aheadOfOrigin === 0 &&
!canMergeFromBase(input)
!canMergeFromBase(input);
if (!canMergeFromTopLevelStatus) {
return false;
}
if (!hasPullRequestGithubFacts(github)) {
return input.pullRequestMergeable === "MERGEABLE";
}
return (
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
github.autoMergeRequest === null &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
getAllowedDirectPullRequestMergeActionModels(input).length > 0
);
}
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
return (
input.githubFeaturesEnabled &&
input.githubAutoMergeActionsEnabled &&
input.hasPullRequest &&
input.pullRequestState === "open" &&
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable !== "CONFLICTING" &&
hasPullRequestGithubFacts(github) &&
github.autoMergeRequest === null &&
github.mergeStateStatus === "BLOCKED" &&
github.repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
getAllowedAutoMergeEnableActionModels(input).length > 0
);
}
@@ -436,14 +649,131 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
if (input.pullRequestMergeable === "CONFLICTING") {
return "Merge PR isn't available because the pull request has conflicts";
}
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
return undefined;
}
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
return "Merge PR isn't available here because this repository uses a merge queue";
}
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
return "Merge PR isn't available until GitHub reports the pull request is ready to merge";
}
return undefined;
}
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
return (
input.pullRequestIsDraft ||
input.pullRequestIsMerged ||
input.pullRequestState === "closed" ||
input.pullRequestMergeable === "CONFLICTING"
return !canMergePr(input);
}
function shouldShowPullRequestAction(
input: BuildGitActionsInput,
id: PullRequestActionId,
): boolean {
if (id === "pr") {
return true;
}
if (id === "disable-pr-auto-merge") {
return (
input.githubAutoMergeActionsEnabled &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
);
}
if (isDirectPullRequestMergeActionId(id)) {
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
}
if (isEnablePullRequestAutoMergeActionId(id)) {
return canEnablePrAutoMerge(input) && getAllowedAutoMergeEnableActionIds(input).includes(id);
}
return false;
}
function isDirectPullRequestMergeActionId(
id: PullRequestActionId,
): id is PullRequestDirectMergeActionId {
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.some((model) => model.id === id);
}
function isEnablePullRequestAutoMergeActionId(
id: PullRequestActionId,
): id is PullRequestAutoMergeEnableActionId {
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.some((model) => model.id === id);
}
function getAllowedDirectPullRequestMergeActionIds(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionId[] {
return getAllowedDirectPullRequestMergeActionModels(input).map((model) => model.id);
}
function getAllowedAutoMergeEnableActionIds(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionId[] {
return getAllowedAutoMergeEnableActionModels(input).map((model) => model.id);
}
function getAllowedDirectPullRequestMergeActionModels(
input: BuildGitActionsInput,
): readonly PullRequestDirectMergeActionModel[] {
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.filter((model) =>
isPullRequestMergeMethodAllowed(input, model.method),
);
}
function getAllowedAutoMergeEnableActionModels(
input: BuildGitActionsInput,
): readonly PullRequestAutoMergeEnableActionModel[] {
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.filter((model) =>
isPullRequestMergeMethodAllowed(input, model.method),
);
}
function getPreferredDirectPullRequestMergeActionModel(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionModel | null {
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
function getPreferredEnablePullRequestAutoMergeActionModel(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionModel | null {
const allowed = getAllowedAutoMergeEnableActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
function isPullRequestMergeMethodAllowed(
input: BuildGitActionsInput,
method: CheckoutPrMergeMethod,
): boolean {
const repository = input.pullRequestGithub?.repository;
if (!repository) {
return true;
}
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
function hasPullRequestGithubFacts(
github: PullRequestGithubStatus | null,
): github is NonNullable<PullRequestGithubStatus> {
return github !== null && github !== undefined;
}
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
}

View File

@@ -14,6 +14,24 @@ import {
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
const githubStatus: CheckoutPrStatus["github"] = {
mergeStateStatus: null,
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
};
const baseStatus: CheckoutPrStatus = {
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
@@ -26,6 +44,7 @@ const baseStatus: CheckoutPrStatus = {
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
github: githubStatus,
};
const baseTimeline: PullRequestTimeline = {

View File

@@ -11,6 +11,7 @@ import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -169,6 +170,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
cwd,
enabled: isGit,
});
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
});
// Ship default persistence
const shipDefaultStorageKey = useMemo(() => {
@@ -240,6 +246,20 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
),
};
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
),
};
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
);
const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
@@ -256,9 +276,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
const runEnablePrAutoMerge = useCheckoutGitActionsStore((s) => s.enablePrAutoMerge);
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
@@ -348,6 +373,32 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runEnablePrAutoMerge({ serverId, cwd, method })
.then(() => {
toastActionSuccess("Auto-merge enabled");
return;
})
.catch((err) => {
toastActionError(err, "Failed to enable auto-merge");
});
},
[cwd, persistShipDefault, runEnablePrAutoMerge, serverId, toastActionError, toastActionSuccess],
);
const handleDisablePrAutoMerge = useCallback(() => {
void runDisablePrAutoMerge({ serverId, cwd })
.then(() => {
toastActionSuccess("Auto-merge disabled");
return;
})
.catch((err) => {
toastActionError(err, "Failed to disable auto-merge");
});
}, [cwd, runDisablePrAutoMerge, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toast.error("Base ref unavailable");
@@ -389,31 +440,59 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => {
const archiveWorktreeAfterConfirmation = useCallback(async () => {
const worktreePath = status?.cwd;
if (!worktreePath) {
toast.error("Worktree path unavailable");
return;
}
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
const workspace = workspaceList.find(
(candidate) => candidate.workspaceDirectory === worktreePath,
);
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: workspace?.diffStat ?? null,
});
if (!confirmed) {
return;
}
const archivedWorkspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaces: workspaceList,
workspaceDirectory: worktreePath,
}) ?? worktreePath;
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaces?.values() ?? [],
workspaces: workspaceList,
}) as Href,
);
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
}, [
branchLabel,
cwd,
gitStatus?.aheadOfOrigin,
gitStatus?.isDirty,
runArchiveWorktree,
serverId,
status?.cwd,
toast,
toastActionError,
]);
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
const derived = deriveGitActionsState({
isGit,
status,
@@ -437,11 +516,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shouldPromoteArchive,
} = derived;
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
});
const handlePrAction = useCallback(() => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
@@ -455,12 +529,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return buildGitActions({
isGit,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
pullRequestState: narrowPullRequestState(prStatus?.state),
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
pullRequestGithub: prStatus?.github ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
@@ -522,6 +598,30 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.mergePrRebase,
handler: () => handleMergePr("rebase"),
},
"enable-pr-auto-merge-squash": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
status: enablePrAutoMergeStatuses.squash,
icon: icons.mergePrSquash,
handler: () => handleEnablePrAutoMerge("squash"),
},
"enable-pr-auto-merge-merge": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
status: enablePrAutoMergeStatuses.merge,
icon: icons.mergePrMerge,
handler: () => handleEnablePrAutoMerge("merge"),
},
"enable-pr-auto-merge-rebase": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
status: enablePrAutoMergeStatuses.rebase,
icon: icons.mergePrRebase,
handler: () => handleEnablePrAutoMerge("rebase"),
},
"disable-pr-auto-merge": {
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
status: disablePrAutoMergeStatus,
icon: icons.viewPr,
handler: handleDisablePrAutoMerge,
},
"merge-branch": {
disabled: isActionDisabled(actionsDisabled, mergeStatus),
status: mergeStatus,
@@ -551,11 +651,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
@@ -571,6 +673,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
@@ -580,6 +686,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query";
describe("githubSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"github-search",
"server-1",
"/repo",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual(
["github-search", "server-1", "/repo", "123", "github-issue,github-pr"],
);
});
});
describe("buildGithubSearchQueryOptions", () => {
it("forwards kinds to the GitHub search request when specified", async () => {
const requests: unknown[] = [];
const query = buildGithubSearchQueryOptions({
client: {
async searchGitHub(options) {
requests.push(options);
return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" };
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["github-pr"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]);
});
});

View File

@@ -0,0 +1,64 @@
import { useQuery } from "@tanstack/react-query";
import type { GitHubSearchRequest, GitHubSearchResponse } from "@server/shared/messages";
export const GITHUB_SEARCH_STALE_TIME = 30_000;
export type GitHubSearchPayload = GitHubSearchResponse["payload"];
export interface GitHubSearchClient {
searchGitHub: (
options: {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchRequest["kinds"];
},
requestId?: string,
) => Promise<GitHubSearchPayload>;
}
interface GitHubSearchQueryInput {
client: GitHubSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: GitHubSearchRequest["kinds"];
enabled: boolean;
}
export function githubSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: GitHubSearchRequest["kinds"],
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["github-search", serverId, cwd, trimmedQuery] as const;
}
return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const;
}
export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
const query = input.query.trim();
return {
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
queryFn: async (): Promise<GitHubSearchPayload> => {
if (!input.client) {
throw new Error("Host is not connected");
}
const request = { cwd: input.cwd, query, limit: 20 };
if (input.kinds) {
return input.client.searchGitHub({ ...request, kinds: input.kinds });
}
return input.client.searchGitHub(request);
},
enabled: input.enabled && Boolean(input.client),
staleTime: GITHUB_SEARCH_STALE_TIME,
};
}
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
return useQuery(buildGithubSearchQueryOptions(input));
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeArchiveConfirmationMessage,
buildWorktreeArchiveRiskReasons,
} from "@/git/worktree-archive-warning";
describe("worktree archive warning", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
worktreeName: "feature",
isDirty: false,
aheadOfOrigin: 0,
diffStat: null,
}),
).toBeNull();
});
it("explains uncommitted line changes", () => {
expect(
buildWorktreeArchiveRiskReasons({
isDirty: true,
aheadOfOrigin: 0,
diffStat: { additions: 12, deletions: 1 },
}),
).toEqual(["Uncommitted changes (12 added lines, 1 deleted line)"]);
});
it("treats nonzero diff stats as dirty when dirty state is missing", () => {
expect(
buildWorktreeArchiveRiskReasons({
isDirty: undefined,
aheadOfOrigin: 0,
diffStat: { additions: 4, deletions: 0 },
}),
).toEqual(["Uncommitted changes (4 added lines)"]);
});
it("explains unpushed commits", () => {
expect(
buildWorktreeArchiveRiskReasons({
isDirty: false,
aheadOfOrigin: 2,
diffStat: null,
}),
).toEqual(["2 unpushed commits"]);
});
it("includes every archive risk in the confirmation copy", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
worktreeName: "risky-feature",
isDirty: true,
aheadOfOrigin: 1,
diffStat: { additions: 1, deletions: 3 },
}),
).toBe("Uncommitted changes (1 added line, 3 deleted lines)\n1 unpushed commit");
});
});

View File

@@ -0,0 +1,79 @@
import { confirmDialog } from "@/utils/confirm-dialog";
export interface WorktreeArchiveRisk {
isDirty?: boolean | null;
aheadOfOrigin?: number | null;
diffStat?: { additions: number; deletions: number } | null;
}
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
worktreeName: string;
}
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
return count === 1 ? singular : plural;
}
function formatDiffStat(diffStat: WorktreeArchiveRisk["diffStat"]): string | null {
if (!diffStat) {
return null;
}
const parts: string[] = [];
if (diffStat.additions > 0) {
parts.push(`${diffStat.additions} added ${pluralize(diffStat.additions, "line")}`);
}
if (diffStat.deletions > 0) {
parts.push(`${diffStat.deletions} deleted ${pluralize(diffStat.deletions, "line")}`);
}
return parts.length > 0 ? parts.join(", ") : null;
}
export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): string[] {
const reasons: string[] = [];
const diffStat = input.diffStat;
const hasDiffStatChanges = diffStat ? diffStat.additions > 0 || diffStat.deletions > 0 : false;
const hasUncommittedChanges =
input.isDirty === true || (input.isDirty == null && hasDiffStatChanges);
if (hasUncommittedChanges) {
const diffStatLabel = formatDiffStat(diffStat);
reasons.push(diffStatLabel ? `Uncommitted changes (${diffStatLabel})` : "Uncommitted changes");
}
if ((input.aheadOfOrigin ?? 0) > 0) {
const aheadOfOrigin = input.aheadOfOrigin ?? 0;
reasons.push(`${aheadOfOrigin} unpushed ${pluralize(aheadOfOrigin, "commit")}`);
}
return reasons;
}
export function buildWorktreeArchiveConfirmationMessage(
input: WorktreeArchiveConfirmationInput,
): string | null {
const reasons = buildWorktreeArchiveRiskReasons(input);
if (reasons.length === 0) {
return null;
}
return reasons.join("\n");
}
export async function confirmRiskyWorktreeArchive(
input: WorktreeArchiveConfirmationInput,
): Promise<boolean> {
const message = buildWorktreeArchiveConfirmationMessage(input);
if (!message) {
return true;
}
return await confirmDialog({
title: `Archive "${input.worktreeName}"?`,
message,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
});
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-image-manipulator", () => ({
SaveFormat: { PNG: "png" },
ImageManipulator: {
manipulate: (_source: string) => ({
async renderAsync() {
return {
release() {},
async saveAsync(options: { format?: string }) {
return {
uri:
options.format === "png"
? "file:///cache/ImageManipulator/safe-picked.png"
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
width: 100,
height: 100,
};
},
};
},
release() {},
}),
},
}));
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
describe("native image attachment picker", () => {
it("preserves native picked JPEG and PNG attachment inputs", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.JPG",
mimeType: "image/jpeg",
fileName: "picked.jpeg",
},
{
uri: "file:///photos/screenshot.png",
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
mimeType: "image/jpeg",
fileName: "picked.jpg",
},
{
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
});
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.HEIC",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
});

View File

@@ -0,0 +1,129 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
interface SupportedPickedImageFormat {
mimeType: "image/jpeg" | "image/png";
extension: "jpg" | "png";
}
const JPEG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/jpeg",
extension: "jpg",
};
const PNG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/png",
extension: "png",
};
function extensionFromPath(path: string | null | undefined): string | null {
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
return match?.[1]?.toLowerCase() ?? null;
}
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
if (extension === "jpg" || extension === "jpeg") {
return JPEG_FORMAT;
}
if (extension === "png") {
return PNG_FORMAT;
}
return null;
}
function supportedFormatForMimeType(
mimeType: string | null | undefined,
): SupportedPickedImageFormat | null {
const normalizedMimeType = mimeType?.toLowerCase();
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
return JPEG_FORMAT;
}
if (normalizedMimeType === "image/png") {
return PNG_FORMAT;
}
return null;
}
function pickedAssetSupportedFormat(
asset: ExpoImagePickerAssetLike,
): SupportedPickedImageFormat | null {
const uriExtension = extensionFromPath(asset.uri);
if (uriExtension) {
return supportedFormatForExtension(uriExtension);
}
return (
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
supportedFormatForMimeType(asset.mimeType)
);
}
function replaceFileExtension(
fileName: string | null | undefined,
extension: SupportedPickedImageFormat["extension"],
): string | null {
if (!fileName) {
return null;
}
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
}
async function exportPickedImageAsPng(uri: string): Promise<string> {
const context = ImageManipulator.manipulate(uri);
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
try {
image = await context.renderAsync();
const result = await image.saveAsync({
format: SaveFormat.PNG,
});
return result.uri;
} finally {
image?.release();
context.release();
}
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
const supportedFormat = pickedAssetSupportedFormat(asset);
if (supportedFormat) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: supportedFormat.mimeType,
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
};
}
const convertedUri = await exportPickedImageAsPng(asset.uri);
return {
source: { kind: "file_uri", uri: convertedUri },
mimeType: PNG_FORMAT.mimeType,
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
};
}),
);
}
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
throw new Error("Desktop dialog API is not available on native.");
}

View File

@@ -0,0 +1,88 @@
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 AssistantFileLinkInteraction,
} from "@/utils/assistant-file-link-resolver";
import type { InlinePathTarget } from "@/utils/inline-path";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {
client?: DaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkActions {
prefetch(input: Omit<AssistantFileLinkInteraction, "context">): void;
open(input: Omit<AssistantFileLinkInteraction, "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) {
onOpenWorkspaceFile?.(target);
},
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,119 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIosHardwareKeyboardSubmit } from "./use-ios-hardware-keyboard-submit";
import {
emitHardwareKeyboardSubmitForTest,
resetHardwareKeyboardSubmitForTest,
getHardwareKeyboardSubmitEnabledForTest,
setHardwareKeyboardSubmitEnabled,
} from "@/native/ios-hardware-keyboard-submit";
describe("useIosHardwareKeyboardSubmit", () => {
beforeEach(() => {
resetHardwareKeyboardSubmitForTest();
});
it("submits when the focused composer receives a hardware keyboard submit event", () => {
const onSubmit = vi.fn();
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: true,
onSubmit,
}),
);
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("does not submit when the composer is blurred", () => {
const onSubmit = vi.fn();
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: false,
onSubmit,
}),
);
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).not.toHaveBeenCalled();
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
});
it("does not submit while the default send action is disabled", () => {
const onSubmit = vi.fn();
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: false,
onSubmit,
}),
);
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("does not disable native hardware submit when mounted disabled", () => {
setHardwareKeyboardSubmitEnabled(true);
renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: false,
onSubmit: vi.fn(),
}),
);
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
});
it("disables native hardware submit when focus moves away", () => {
const onSubmit = vi.fn();
const { rerender } = renderHook(
({ isEnabled }) =>
useIosHardwareKeyboardSubmit({
isEnabled,
onSubmit,
}),
{ initialProps: { isEnabled: true } },
);
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
rerender({ isEnabled: false });
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
});
it("unsubscribes on unmount", () => {
const onSubmit = vi.fn();
const { unmount } = renderHook(() =>
useIosHardwareKeyboardSubmit({
isEnabled: true,
onSubmit,
}),
);
unmount();
act(() => {
emitHardwareKeyboardSubmitForTest();
});
expect(onSubmit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import { useEffect, useRef } from "react";
import {
addHardwareKeyboardSubmitListener,
setHardwareKeyboardSubmitEnabled,
} from "@/native/ios-hardware-keyboard-submit";
interface UseIosHardwareKeyboardSubmitInput {
isEnabled: boolean;
onSubmit: () => void;
}
export function useIosHardwareKeyboardSubmit(input: UseIosHardwareKeyboardSubmitInput) {
const onSubmitRef = useRef(input.onSubmit);
useEffect(() => {
onSubmitRef.current = input.onSubmit;
}, [input.onSubmit]);
useEffect(() => {
if (!input.isEnabled) {
return;
}
const subscription = addHardwareKeyboardSubmitListener(() => {
onSubmitRef.current();
});
setHardwareKeyboardSubmitEnabled(true);
return () => {
setHardwareKeyboardSubmitEnabled(false);
subscription.remove();
};
}, [input.isEnabled]);
}

View File

@@ -54,6 +54,24 @@ vi.mock("@/runtime/host-runtime", () => ({
const cwd = "/repo";
const serverId = "server-1";
const githubStatus: CheckoutPrStatus["github"] = {
mergeStateStatus: null,
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
};
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
return {
number: 42,
@@ -69,6 +87,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
reviewDecision: null,
repoOwner: "getpaseo",
repoName: "paseo",
github: githubStatus,
...overrides,
};
}

View File

@@ -255,6 +255,7 @@ describe("useProjects", () => {
"hostCount",
"hosts",
"onlineHostCount",
"projectCustomName",
"projectKey",
"projectName",
"totalWorkspaceCount",

View File

@@ -31,6 +31,8 @@ export interface SidebarWorkspaceEntry {
statusBucket: SidebarStateBucket;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
archiveHasUncommittedChanges: boolean | null;
archiveUnpushedCommitCount: number | null;
scripts: WorkspaceDescriptor["scripts"];
hasRunningScripts: boolean;
}
@@ -69,6 +71,8 @@ function createStructuralWorkspaceEntry(input: {
statusBucket: "done",
archivingAt: null,
diffStat: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
};
@@ -91,6 +95,8 @@ export function createSidebarWorkspaceEntry(input: {
statusBucket: input.workspace.status,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};

View File

@@ -0,0 +1,29 @@
import { requireNativeModule, type EventSubscription } from "expo-modules-core";
type HardwareKeyboardSubmitHandler = () => void;
interface PaseoHardwareKeyboardModule {
setHardwareKeyboardSubmitEnabled(enabled: boolean): void;
addListener(
eventName: "onHardwareKeyboardSubmit",
handler: HardwareKeyboardSubmitHandler,
): EventSubscription;
}
const module = requireNativeModule<PaseoHardwareKeyboardModule>("PaseoHardwareKeyboard");
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
module.setHardwareKeyboardSubmitEnabled(enabled);
}
export function addHardwareKeyboardSubmitListener(handler: HardwareKeyboardSubmitHandler) {
return module.addListener("onHardwareKeyboardSubmit", handler);
}
export function emitHardwareKeyboardSubmitForTest() {}
export function resetHardwareKeyboardSubmitForTest() {}
export function getHardwareKeyboardSubmitEnabledForTest() {
return false;
}

View File

@@ -0,0 +1,34 @@
import type { EventSubscription } from "expo-modules-core";
type HardwareKeyboardSubmitHandler = () => void;
const testHandlers = new Set<HardwareKeyboardSubmitHandler>();
let isEnabledForTest = false;
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
isEnabledForTest = enabled;
}
export function addHardwareKeyboardSubmitListener(
handler: HardwareKeyboardSubmitHandler,
): EventSubscription {
testHandlers.add(handler);
return {
remove: () => {
testHandlers.delete(handler);
},
};
}
export function emitHardwareKeyboardSubmitForTest() {
testHandlers.forEach((handler) => handler());
}
export function resetHardwareKeyboardSubmitForTest() {
testHandlers.clear();
isEnabledForTest = false;
}
export function getHardwareKeyboardSubmitEnabledForTest() {
return isEnabledForTest;
}

View File

@@ -302,6 +302,58 @@ function makeProbeMap(
}
describe("HostRuntimeController", () => {
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
const oldRelay: HostConnection = {
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_old",
};
const newRelay: HostConnection = {
...oldRelay,
daemonPublicKeyB64: "pk_new",
};
const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = [];
const controller = new HostRuntimeController({
host: makeHost({
connections: [oldRelay],
preferredConnectionId: oldRelay.id,
}),
deps: {
createClient: ({ connection }) => {
const client = new FakeDaemonClient();
createdClients.push({ client, connection });
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ host, connection }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: connection.id,
}),
getClientId: async () => "cid_test_runtime",
},
});
await (
controller as unknown as {
switchToConnection: (input: { connectionId: string }) => Promise<void>;
}
).switchToConnection({ connectionId: oldRelay.id });
expect(controller.getSnapshot().client).toBe(createdClients[0]?.client);
await controller.updateHost(
makeHost({
connections: [newRelay],
preferredConnectionId: newRelay.id,
}),
);
expect(createdClients.map((entry) => entry.connection)).toEqual([oldRelay, newRelay]);
expect(createdClients[0]?.client.closeCalls).toBe(1);
expect(controller.getSnapshot().client).toBe(createdClients[1]?.client);
});
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
const host = makeHost({
connections: [

View File

@@ -602,8 +602,20 @@ export class HostRuntimeController {
}
async updateHost(host: HostProfile): Promise<void> {
const activeConnectionId = this.snapshot.activeConnectionId;
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
this.host = host;
this.trackConnectionFirstSeen();
const nextActiveConnection = findConnectionById(this.host, activeConnectionId);
if (
activeConnectionId &&
previousActiveConnection &&
nextActiveConnection &&
!equal(previousActiveConnection, nextActiveConnection)
) {
this.connectionLastProbedAt.delete(activeConnectionId);
await this.switchToConnection({ connectionId: activeConnectionId });
}
await this.runProbeCycleNow();
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return {
@@ -17,7 +17,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
};
}
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
function prAttachment(
item: GitHubSearchItem,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item };
}
@@ -94,3 +96,54 @@ describe("syncPickerPrAttachment", () => {
expect(result.attachments).toHaveLength(1);
});
});
describe("findCheckoutHintPrAttachment", () => {
it("returns the first attached PR that is not selected or dismissed", () => {
const first = prAttachment(makePrItem(101, "A"));
const second = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), first, second],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBe(first);
});
it("skips the selected PR and offers the next attached PR", () => {
const selected = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [selected, next],
selectedItem: { kind: "github-pr", item: selected.item },
dismissedPrNumbers: new Set(),
}),
).toBe(next);
});
it("skips dismissed PRs and ignores issues", () => {
const dismissed = prAttachment(makePrItem(101, "A"));
const next = prAttachment(makePrItem(202, "B"));
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44), dismissed, next],
selectedItem: null,
dismissedPrNumbers: new Set([101]),
}),
).toBe(next);
});
it("returns null when only issues qualify", () => {
expect(
findCheckoutHintPrAttachment({
attachments: [issueAttachment(44)],
selectedItem: null,
dismissedPrNumbers: new Set(),
}),
).toBeNull();
});
});

View File

@@ -33,3 +33,22 @@ export function syncPickerPrAttachment(input: {
return { attachments: nextAttachments, attachedPrNumber };
}
export function findCheckoutHintPrAttachment(input: {
attachments: ReadonlyArray<UserComposerAttachment>;
selectedItem: PickerItem | null;
dismissedPrNumbers: ReadonlySet<number>;
}): Extract<UserComposerAttachment, { kind: "github_pr" }> | null {
const selectedPrNumber =
input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null;
for (const attachment of input.attachments) {
if (attachment.kind !== "github_pr") continue;
const prNumber = attachment.item.number;
if (prNumber === selectedPrNumber) continue;
if (input.dismissedPrNumbers.has(prNumber)) continue;
return attachment;
}
return null;
}

View File

@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { FileDropZone } from "@/components/file-drop-zone";
@@ -19,6 +19,7 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
@@ -30,14 +31,33 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
import {
pickerItemToCheckoutRequest,
type PickerCheckoutRequest,
type PickerItem,
} from "./new-workspace-picker-item";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
function resolveCheckoutRequest(
selectedItem: PickerItem | null,
currentBranch: string | null,
): PickerCheckoutRequest | undefined {
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
if (selectedCheckoutRequest) return selectedCheckoutRequest;
if (!currentBranch) return undefined;
return {
action: "branch-off",
refName: currentBranch,
};
}
interface NewWorkspaceScreenProps {
serverId: string;
sourceDirectory: string;
projectId?: string;
displayName?: string;
}
@@ -128,6 +148,46 @@ function RefPickerTrigger({
);
}
function CheckoutHintBadge({
prNumber,
onAccept,
onDismiss,
iconColor,
iconSize,
}: {
prNumber: number;
onAccept: () => void;
onDismiss: () => void;
iconColor: string;
iconSize: number;
}) {
return (
<View style={styles.checkoutHintBadge}>
<Text style={styles.badgeText} numberOfLines={1}>
Check out PR #{prNumber}?
</Text>
<Pressable
testID="new-workspace-checkout-hint-accept"
onPress={onAccept}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={`Check out PR #${prNumber}`}
>
<Check size={iconSize} color={iconColor} />
</Pressable>
<Pressable
testID="new-workspace-checkout-hint-dismiss"
onPress={onDismiss}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={`Dismiss PR #${prNumber} checkout hint`}
>
<X size={iconSize} color={iconColor} />
</Pressable>
</View>
);
}
function PickerOptionItem({
testID,
label,
@@ -230,6 +290,17 @@ function computePickerOptionData(
return { options: timedOptions.map((t) => t.option), itemById: idMap };
}
function normalizeBranchDetails(
data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
| undefined,
): Array<{ name: string; committerDate: number }> {
const details = data?.branchDetails;
if (details && details.length > 0) return details;
const names = data?.branches ?? [];
return names.map((name) => ({ name, committerDate: 0 }));
}
interface SubmitDraftInput {
serverId: string;
draftKey: string;
@@ -331,6 +402,47 @@ function computeWorkspaceTitle(
);
}
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
const numbers = new Set<number>();
for (const attachment of attachments) {
if (attachment.kind === "github_pr") {
numbers.add(attachment.item.number);
}
}
return numbers;
}
function pruneDismissedCheckoutHintPrNumbers(
dismissed: ReadonlySet<number>,
attached: ReadonlySet<number>,
): ReadonlySet<number> {
let changed = false;
const next = new Set<number>();
for (const prNumber of dismissed) {
if (attached.has(prNumber)) {
next.add(prNumber);
} else {
changed = true;
}
}
return changed ? next : dismissed;
}
function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttachment>) {
const [dismissedPrNumbers, setDismissedPrNumbers] = useState<ReadonlySet<number>>(
() => new Set(),
);
const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]);
useEffect(() => {
setDismissedPrNumbers((current) =>
pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers),
);
}, [attachedPrNumbers]);
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
@@ -385,6 +497,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
export function NewWorkspaceScreen({
serverId,
sourceDirectory,
projectId,
displayName: displayNameProp,
}: NewWorkspaceScreenProps) {
const { theme } = useUnistyles();
@@ -400,7 +513,7 @@ export function NewWorkspaceScreen({
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
@@ -414,7 +527,6 @@ export function NewWorkspaceScreen({
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace;
const selectedItem = pickerSelection?.item ?? null;
const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -429,6 +541,10 @@ export function NewWorkspaceScreen({
}),
});
const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
useCheckoutHintDismissals(chatDraft.attachments);
const selectedItem = manualPickerSelection?.item ?? null;
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
@@ -469,32 +585,23 @@ export function NewWorkspaceScreen({
staleTime: 15_000,
});
const githubPrSearchQuery = useQuery({
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery],
queryFn: async () => {
const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({
cwd: sourceDirectory,
query: debouncedPickerSearchQuery,
limit: 20,
kinds: ["github-pr"],
});
},
const githubPrSearchQuery = useGithubSearchQuery({
client,
serverId,
cwd: sourceDirectory,
query: debouncedPickerSearchQuery,
kinds: ["github-pr"],
enabled: pickerQueryEnabled,
staleTime: 30_000,
});
const branchDetails = useMemo(() => {
const details = branchSuggestionsQuery.data?.branchDetails;
if (details && details.length > 0) return details;
const names = branchSuggestionsQuery.data?.branches ?? [];
return names.map((name) => ({ name, committerDate: 0 }));
}, [branchSuggestionsQuery.data?.branchDetails, branchSuggestionsQuery.data?.branches]);
const branchDetails = useMemo(
() => normalizeBranchDetails(branchSuggestionsQuery.data),
[branchSuggestionsQuery.data],
);
const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
const prItems: GitHubSearchItem[] = useMemo(() => {
if (!githubFeaturesEnabled) return [];
const items = githubPrSearchQuery.data?.items ?? [];
return items.filter((item): item is GitHubSearchItem => item.kind === "pr");
return githubPrSearchQuery.data?.items ?? [];
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
const { options, itemById }: PickerOptionData = useMemo(
@@ -514,18 +621,15 @@ export function NewWorkspaceScreen({
: prOptionId(selectedItem.item.number);
}, [selectedItem]);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
const selectPickerItem = useCallback(
(item: PickerItem) => {
const next = syncPickerPrAttachment({
attachments: chatDraft.attachments,
previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null,
previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null,
item,
});
setPickerSelection({
setManualPickerSelection({
item,
attachedPrNumber: next.attachedPrNumber,
});
@@ -534,9 +638,44 @@ export function NewWorkspaceScreen({
}
setPickerOpen(false);
},
[chatDraft, itemById, pickerSelection?.attachedPrNumber],
[chatDraft, manualPickerSelection?.attachedPrNumber],
);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
selectPickerItem(item);
},
[itemById, selectPickerItem],
);
const checkoutHintPrAttachment = useMemo(
() =>
findCheckoutHintPrAttachment({
attachments: chatDraft.attachments,
selectedItem,
dismissedPrNumbers: dismissedCheckoutHintPrNumbers,
}),
[chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem],
);
const acceptCheckoutHint = useCallback(() => {
if (!checkoutHintPrAttachment) return;
selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item });
}, [checkoutHintPrAttachment, selectPickerItem]);
const dismissCheckoutHint = useCallback(() => {
if (!checkoutHintPrAttachment) return;
const prNumber = checkoutHintPrAttachment.item.number;
setDismissedCheckoutHintPrNumbers((current) => {
if (current.has(prNumber)) return current;
const next = new Set(current);
next.add(prNumber);
return next;
});
}, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]);
const openPicker = useCallback(() => {
setPickerOpen(true);
}, []);
@@ -563,13 +702,18 @@ export function NewWorkspaceScreen({
}, []);
const buildCreateWorktreeInput = useCallback(
(input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => {
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
(input: {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
}): CreatePaseoWorktreeInput => {
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const trimmedPrompt = input.prompt.trim();
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
return {
cwd: input.cwd,
...(projectId ? { projectId } : {}),
worktreeSlug: createNameId(),
...(hasFirstAgentContext
? {
@@ -582,7 +726,7 @@ export function NewWorkspaceScreen({
...checkoutRequest,
};
},
[selectedItem],
[currentBranch, projectId, selectedItem],
);
const ensureWorkspace = useCallback(
@@ -789,6 +933,15 @@ export function NewWorkspaceScreen({
renderOption={renderPickerOption}
/>
</View>
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
prNumber={checkoutHintPrAttachment.item.number}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
@@ -866,6 +1019,23 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
checkoutHintBadge: {
flexDirection: "row",
alignItems: "center",
height: 28,
maxWidth: 240,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
},
checkoutHintAction: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.full,
},
badgeHovered: {
backgroundColor: theme.colors.surface2,
},

View File

@@ -3,7 +3,7 @@ import { Image, Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronDown, MoreVertical, Plus } from "lucide-react-native";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type {
PaseoConfigRaw,
@@ -234,7 +234,7 @@ function ProjectSettingsBody({
<View style={styles.headerBlock}>
<View style={styles.titleRow}>
<ProjectTitleIcon host={selectedHost} projectName={project.projectName} />
<Text style={styles.projectTitle}>{project.projectName}</Text>
<ProjectNameEditor project={project} client={client} />
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View>
@@ -771,6 +771,124 @@ function ResolveSpinnerColor(): string {
return styles.spinnerColor.color;
}
interface ProjectNameEditorProps {
project: ProjectSummary;
client: DaemonClient;
}
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
const queryClient = useQueryClient();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(project.projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsEditing(false);
toast.show("Project renamed", { variant: "success" });
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Couldn't rename project";
toast.show(message, { variant: "error" });
},
});
const handleStartEdit = useCallback(() => {
setValue(project.projectCustomName ?? "");
setIsEditing(true);
}, [project.projectCustomName]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setValue(project.projectCustomName ?? "");
}, [project.projectCustomName]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === (project.projectCustomName ?? null)) {
setIsEditing(false);
return;
}
renameMutation.mutate(next);
}, [value, project.projectCustomName, renameMutation]);
const handleReset = useCallback(() => {
renameMutation.mutate(null);
}, [renameMutation]);
if (!isEditing) {
return (
<View style={styles.nameEditorRow}>
<Text style={styles.projectTitle} numberOfLines={1}>
{project.projectName}
</Text>
<Pressable
testID="project-name-edit-button"
accessibilityLabel="Rename project"
onPress={handleStartEdit}
hitSlop={8}
style={styles.nameEditorIconButton}
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{project.projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityLabel="Reset project name to default"
onPress={handleReset}
disabled={renameMutation.isPending}
hitSlop={8}
style={styles.nameEditorResetButton}
>
<Text style={styles.nameEditorResetText}>Reset</Text>
</Pressable>
) : null}
</View>
);
}
return (
<View style={styles.nameEditorRow}>
<TextInput
testID="project-name-input"
accessibilityLabel="Project name"
value={value}
onChangeText={setValue}
placeholder={project.projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}
editable={!renameMutation.isPending}
onSubmitEditing={handleSave}
returnKeyType="done"
/>
<Pressable
testID="project-name-save-button"
accessibilityLabel="Save project name"
onPress={handleSave}
disabled={renameMutation.isPending}
hitSlop={8}
style={styles.nameEditorIconButton}
>
<Check size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
<Pressable
testID="project-name-cancel-button"
accessibilityLabel="Cancel renaming"
onPress={handleCancel}
disabled={renameMutation.isPending}
hitSlop={8}
style={styles.nameEditorIconButton}
>
<X size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
</View>
);
}
function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; projectName: string }) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
@@ -1140,6 +1258,37 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
nameEditorRow: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
nameEditorIconButton: {
padding: theme.spacing[1],
},
nameEditorInput: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
minWidth: 0,
},
nameEditorResetButton: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
},
nameEditorResetText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
titleIcon: {
width: 28,
height: 28,

View File

@@ -720,9 +720,16 @@ function SettingsSidebar({
}, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
const padding = useWindowControlsPadding("sidebar");
const isDesktop = layout === "desktop";
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const containerStyle = useMemo(
() => [
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
isDesktop ? { paddingTop: insets.top } : null,
],
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";

View File

@@ -202,7 +202,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
};
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return { mcp: { injectIntoAgents: false }, providers };
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
}
function descendants(el: HTMLElement): HTMLElement[] {
@@ -347,6 +347,26 @@ describe("ProvidersSection", () => {
expect(patchConfigMock).toHaveBeenCalledWith({
providers: { claude: { enabled: false } },
});
expect(refreshMock).not.toHaveBeenCalled();
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
});
it("forces a provider snapshot refresh from the settings refresh action", async () => {
snapshotState.entries = [claudeEntry];
configState.config = makeConfig();
render();
const refreshButton = container?.querySelector<HTMLElement>(
'[role="button"][aria-label="Refresh providers"]',
);
expect(refreshButton).not.toBeNull();
await act(async () => {
refreshButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(patchConfigMock).not.toHaveBeenCalled();
});
});

View File

@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
id: string;
projectId: string;
projectDisplayName: string;
projectCustomName?: string | null;
projectRootPath: string;
workspaceDirectory: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
@@ -134,6 +135,7 @@ export function normalizeWorkspaceDescriptor(
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
workspaceDirectory: payload.workspaceDirectory,
projectKind: payload.projectKind,

View File

@@ -252,13 +252,11 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
bullet_list: {
marginVertical: theme.spacing[2],
paddingLeft: 0,
width: "100%" as const,
},
ordered_list: {
marginVertical: theme.spacing[2],
paddingLeft: 0,
width: "100%" as const,
},

View File

@@ -1,5 +1,6 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
@@ -15,6 +16,14 @@ interface TerminalSize {
cols: number;
}
interface TerminalKeyRecord {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
type BrowserTerminal = TerminalSize & {
refresh: (start: number, end: number) => void;
reset: () => void;
@@ -26,6 +35,8 @@ interface MountedTerminal {
runtime: TerminalEmulatorRuntime;
inputs: string[];
sizes: TerminalSize[];
terminalKeys: TerminalKeyRecord[];
inputModeChanges: TerminalInputModeState[];
}
const mountedTerminals: MountedTerminal[] = [];
@@ -67,6 +78,8 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
const sizes: TerminalSize[] = [];
const inputs: string[] = [];
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
@@ -76,6 +89,12 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
onResize: (size) => {
sizes.push(size);
},
onTerminalKey: (key) => {
terminalKeys.push(key);
},
onInputModeChange: (state) => {
inputModeChanges.push(state);
},
},
});
runtime.mount({
@@ -89,7 +108,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
},
});
const mounted = { host, root, runtime, inputs, sizes };
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
mountedTerminals.push(mounted);
return mounted;
}
@@ -110,6 +129,32 @@ function getBrowserTerminal(): BrowserTerminal {
return terminal;
}
function dispatchTerminalKey(input: {
host: HTMLElement;
key: string;
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
}): boolean {
const textarea = input.host.querySelector<HTMLTextAreaElement>("textarea");
if (!textarea) {
throw new Error("Expected xterm textarea to be mounted");
}
textarea.focus();
return textarea.dispatchEvent(
new KeyboardEvent("keydown", {
key: input.key,
shiftKey: input.shiftKey ?? false,
ctrlKey: input.ctrlKey ?? false,
altKey: input.altKey ?? false,
metaKey: input.metaKey ?? false,
bubbles: true,
cancelable: true,
}),
);
}
afterEach(() => {
for (const mounted of mountedTerminals.splice(0)) {
mounted.runtime.unmount();
@@ -162,6 +207,73 @@ describe("terminal emulator runtime in a real browser", () => {
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
});
it("intercepts Shift+Enter only after enhanced terminal input mode is active", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
dispatchTerminalKey({
host: mounted.host,
key: "Enter",
shiftKey: true,
});
await nextFrame();
expect(mounted.terminalKeys).toEqual([]);
mounted.runtime.write({ text: "\x1b[>7u" });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
(state) => state.kittyKeyboardFlags === 7 && !state.win32InputMode,
),
});
dispatchTerminalKey({
host: mounted.host,
key: "Enter",
shiftKey: true,
});
await nextFrame();
expect(mounted.terminalKeys).toEqual([
{
key: "Enter",
ctrl: false,
shift: true,
alt: false,
meta: false,
},
]);
mounted.terminalKeys.length = 0;
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
(state) => state.kittyKeyboardFlags === 0 && state.win32InputMode,
),
});
dispatchTerminalKey({
host: mounted.host,
key: "Enter",
shiftKey: true,
});
await nextFrame();
expect(mounted.terminalKeys).toEqual([
{
key: "Enter",
ctrl: false,
shift: true,
alt: false,
meta: false,
},
]);
});
it.each([
{ name: "DA1", bytes: "\x1b[c" },
{ name: "DA1-zero", bytes: "\x1b[0c" },

View File

@@ -176,6 +176,38 @@ describe("terminal-emulator-runtime", () => {
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it("reports input mode changes from terminal output and resets them on snapshots", () => {
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
const inputModeChanges: Array<{ kittyKeyboardFlags: number; win32InputMode: boolean }> = [];
runtime.setCallbacks({
callbacks: {
onInputModeChange: (state) => {
inputModeChanges.push(state);
},
},
});
runtime.write({ text: "\x1b[>7u" });
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [[{ char: "$" }, { char: " " }]],
cursor: {
row: 0,
col: 2,
},
},
});
writeCallbacks[0]?.();
expect(inputModeChanges).toEqual([
{ kittyKeyboardFlags: 7, win32InputMode: false },
{ kittyKeyboardFlags: 0, win32InputMode: false },
]);
});
it("ignores stale duplicate write callbacks from a previous operation", () => {
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
const committed: string[] = [];

View File

@@ -8,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
terminalInputModeStatesEqual,
} from "@server/shared/terminal-input-mode";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -37,6 +42,7 @@ export interface TerminalEmulatorRuntimeCallbacks {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOpenExternalUrl?: (url: string) => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
interface TerminalEmulatorRuntimeDisposables {
@@ -125,6 +131,8 @@ export class TerminalEmulatorRuntime {
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private handleVisibilityRestore = (): void => {
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
@@ -152,6 +160,8 @@ export class TerminalEmulatorRuntime {
input.host.innerHTML = "";
this.lastSize = null;
this.inputModeTracker.reset();
this.emitInputModeChange();
const terminal = new Terminal({
allowProposedApi: true,
@@ -353,6 +363,7 @@ export class TerminalEmulatorRuntime {
altKey: event.altKey,
metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers,
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
})
) {
return true;
@@ -606,6 +617,8 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
this.inputModeTracker.reset();
this.emitInputModeChange();
}
private processOutputQueue(): void {
@@ -640,12 +653,16 @@ export class TerminalEmulatorRuntime {
};
if (operation.type === "clear") {
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
finalizeOperation(operation);
return;
}
if (operation.type === "snapshot") {
this.inputModeTracker.reset();
this.emitInputModeChange();
try {
if (
typeof operation.cols === "number" &&
@@ -661,6 +678,12 @@ export class TerminalEmulatorRuntime {
}
const text = operation.text;
if (operation.type === "write") {
const result = this.inputModeTracker.feed(text);
if (result.changed) {
this.emitInputModeChange();
}
}
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
}, OUTPUT_OPERATION_TIMEOUT_MS);
@@ -682,6 +705,15 @@ export class TerminalEmulatorRuntime {
this.inFlightOutputOperationTimeout = null;
}
private emitInputModeChange(): void {
const state = this.inputModeTracker.getState();
if (terminalInputModeStatesEqual(state, this.lastInputModeState)) {
return;
}
this.lastInputModeState = state;
this.callbacks.onInputModeChange?.(state);
}
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
const documentElement = document.documentElement;
const body = document.body;

View File

@@ -218,6 +218,34 @@ describe("processTimelineResponse", () => {
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("uses the timeline entry timestamp as canonical", () => {
const result = processTimelineResponse({
...baseTimelineInput,
payload: {
...baseTimelineInput.payload,
reset: true,
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
{
...makeTimelineEntry(1, "hello", "user_message"),
timestamp: new Date("2025-01-01T12:00:03Z").toISOString(),
},
{
...makeTimelineEntry(2, "reply"),
timestamp: new Date("2025-01-01T12:00:04Z").toISOString(),
},
],
},
});
const user = result.tail.find((item) => item.kind === "user_message");
const assistant = result.tail.find((item) => item.kind === "assistant_message");
expect(user?.timestamp.toISOString()).toBe("2025-01-01T12:00:03.000Z");
expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z");
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const result = processTimelineResponse({
...baseTimelineInput,

View File

@@ -395,7 +395,10 @@ function applyTimelineIncrementalPath(args: {
if (acceptedUnits.length > 0) {
if (payload.direction === "before") {
const olderTail = hydrateStreamState(
acceptedUnits.map(({ event, timestamp }) => ({ event, timestamp })),
acceptedUnits.map(({ event, timestamp }) => ({
event,
timestamp,
})),
{ source: "canonical" },
);
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
@@ -414,7 +417,9 @@ function applyTimelineIncrementalPath(args: {
} else {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, { source: "canonical" }),
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
@@ -482,7 +487,7 @@ export function processTimelineResponse(
}));
const toHydratedEvents = (
units: typeof timelineUnits,
units: TimelineUnit[],
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
units.map(({ event, timestamp }) => ({ event, timestamp }));

View File

@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { deriveStreamTurnTiming } from "./turn-time";
import type { StreamItem } from "@/types/stream";
function user(id: string, timestamp: Date): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp,
};
}
function assistant(id: string, timestamp: Date): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp,
};
}
describe("deriveStreamTurnTiming", () => {
it("uses the last user message as the running turn start", () => {
const firstUserAt = new Date("2026-05-15T00:00:00.000Z");
const secondUserAt = new Date("2026-05-15T00:01:00.000Z");
const timing = deriveStreamTurnTiming({
agentStatus: "running",
tail: [
user("u1", firstUserAt),
assistant("a1", new Date("2026-05-15T00:00:05.000Z")),
user("u2", secondUserAt),
],
head: [assistant("a2", new Date("2026-05-15T00:01:04.000Z"))],
});
assert.equal(timing.runningStartedAt, secondUserAt);
assert.equal(timing.byAssistantId.has("a2"), false);
});
it("derives completed turn timing from user and assistant item timestamps", () => {
const userAt = new Date("2026-05-15T00:00:00.000Z");
const assistantAt = new Date("2026-05-15T00:00:07.000Z");
const timing = deriveStreamTurnTiming({
agentStatus: "idle",
tail: [
user("u1", userAt),
assistant("a1", assistantAt),
user("u2", new Date("2026-05-15T00:01:00.000Z")),
],
head: [],
});
assert.deepEqual(timing.byAssistantId.get("a1"), {
startedAt: userAt,
completedAt: assistantAt,
durationMs: 7000,
});
});
it("maps multiple assistant chunks in one turn to the same timing", () => {
const userAt = new Date("2026-05-15T00:00:00.000Z");
const firstAssistantAt = new Date("2026-05-15T00:00:03.000Z");
const lastAssistantAt = new Date("2026-05-15T00:00:07.000Z");
const timing = deriveStreamTurnTiming({
agentStatus: "idle",
tail: [
user("u1", userAt),
assistant("a1", firstAssistantAt),
assistant("a2", lastAssistantAt),
],
head: [],
});
const expected = {
startedAt: userAt,
completedAt: lastAssistantAt,
durationMs: 7000,
};
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
});
});

View File

@@ -0,0 +1,84 @@
import type { StreamItem } from "@/types/stream";
export interface TurnTiming {
startedAt: Date;
completedAt: Date;
durationMs: number;
}
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
}
export function deriveStreamTurnTiming(params: {
agentStatus: string;
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
const flushCompletedTurn = () => {
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
return;
}
const timing: TurnTiming = {
startedAt: currentUserAt,
completedAt: currentLastItemAt,
durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()),
};
for (const id of currentAssistantIds) {
byAssistantId.set(id, timing);
}
};
const visitItem = (item: StreamItem) => {
if (item.kind === "user_message") {
flushCompletedTurn();
currentUserAt = item.timestamp;
currentLastItemAt = null;
currentAssistantIds = [];
return;
}
if (!currentUserAt) {
return;
}
currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id);
}
};
for (const item of params.tail) {
visitItem(item);
}
for (const item of params.head) {
visitItem(item);
}
const runningStartedAt =
params.agentStatus === "running"
? (findLastUserMessageTimestamp(params.head) ?? currentUserAt)
: null;
if (params.agentStatus !== "running") {
flushCompletedTurn();
}
return {
byAssistantId,
runningStartedAt,
};
}
function findLastUserMessageTimestamp(items: StreamItem[]): Date | null {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
if (item?.kind === "user_message") {
return item.timestamp;
}
}
return null;
}

View File

@@ -5,12 +5,13 @@ import { applyStreamEvent } from "@/types/stream";
const baseTimestamp = new Date(0);
const assistantChunk = (text: string): AgentStreamEventPayload => ({
const assistantChunk = (text: string, messageId?: string): AgentStreamEventPayload => ({
type: "timeline",
provider: "codex",
item: {
type: "assistant_message",
text,
...(messageId ? { messageId } : {}),
},
});
@@ -131,6 +132,36 @@ describe("applyStreamEvent", () => {
expect(result.tail[0].kind).toBe("assistant_message");
});
it("does not continue a tail assistant message when the incoming message id differs", () => {
const result = applyStreamEvent({
tail: [
{
kind: "assistant_message",
id: "msg-first",
messageId: "msg-first",
text: "First answer.",
timestamp: baseTimestamp,
},
],
head: [],
event: assistantChunk("Second answer.", "msg-second"),
timestamp: baseTimestamp,
});
expect(result.tail).toHaveLength(1);
expect(result.head).toHaveLength(1);
expect(result.tail[0].kind).toBe("assistant_message");
expect(result.head[0].kind).toBe("assistant_message");
if (
result.tail[0].kind === "assistant_message" &&
result.head[0].kind === "assistant_message"
) {
expect(result.tail[0].text).toBe("First answer.");
expect(result.head[0].text).toBe("Second answer.");
expect(result.head[0].messageId).toBe("msg-second");
}
});
it("flushes reasoning when assistant message starts", () => {
let result = applyStreamEvent({
tail: [],

View File

@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import invariant from "tiny-invariant";
import { describe, it } from "vitest";
import {
@@ -18,11 +19,12 @@ type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
function assistantTimeline(
text: string,
provider: AgentProvider = "claude",
messageId?: string,
): AgentStreamEventPayload {
return {
type: "timeline",
provider,
item: { type: "assistant_message", text },
item: { type: "assistant_message", text, ...(messageId ? { messageId } : {}) },
};
}
@@ -312,6 +314,68 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(assistantMessage?.text, "Hello world");
});
it("keeps adjacent assistant timeline items separate when message ids differ", () => {
const state = hydrateStreamState([
{
event: assistantTimeline("First answer.", "codex", "msg-first"),
timestamp: new Date("2025-01-01T10:01:00Z"),
},
{
event: assistantTimeline("Second answer.", "codex", "msg-second"),
timestamp: new Date("2025-01-01T10:01:01Z"),
},
]);
const messages = state.filter((item) => item.kind === "assistant_message");
assert.strictEqual(messages.length, 2);
const first = messages[0];
const second = messages[1];
invariant(first?.kind === "assistant_message");
invariant(second?.kind === "assistant_message");
assert.deepStrictEqual([first.text, second.text], ["First answer.", "Second answer."]);
assert.deepStrictEqual([first.messageId, second.messageId], ["msg-first", "msg-second"]);
});
it("merges adjacent assistant deltas when message ids match", () => {
const state = hydrateStreamState([
{
event: assistantTimeline("Hel", "codex", "msg-same"),
timestamp: new Date("2025-01-01T10:02:00Z"),
},
{
event: assistantTimeline("lo", "codex", "msg-same"),
timestamp: new Date("2025-01-01T10:02:01Z"),
},
]);
const messages = state.filter((item) => item.kind === "assistant_message");
assert.strictEqual(messages.length, 1);
const first = messages[0];
invariant(first?.kind === "assistant_message");
assert.strictEqual(first.text, "Hello");
assert.strictEqual(first.id, "msg-same");
assert.strictEqual(first.messageId, "msg-same");
});
it("preserves old assistant merge behavior when message ids are absent", () => {
const state = hydrateStreamState([
{
event: assistantTimeline("Hel", "codex"),
timestamp: new Date("2025-01-01T10:03:00Z"),
},
{
event: assistantTimeline("lo", "codex"),
timestamp: new Date("2025-01-01T10:03:01Z"),
},
]);
const messages = state.filter((item) => item.kind === "assistant_message");
assert.strictEqual(messages.length, 1);
const first = messages[0];
invariant(first?.kind === "assistant_message");
assert.strictEqual(first.text, "Hello");
});
it("merges running and completed events by callId", () => {
const callId = "tool-merge-1";
const updates = [
@@ -776,3 +840,99 @@ describe("stream reducer canonical tool calls", () => {
);
});
});
describe("turn lifecycle events", () => {
it("finalizes active stream items without adding timeline rows", () => {
const startedAt = new Date("2025-01-01T12:00:00Z");
const completedAt = new Date("2025-01-01T12:00:05Z");
let state = reduceStreamUpdate([], { type: "turn_started", provider: "claude" }, startedAt);
state = reduceStreamUpdate(
state,
{ type: "timeline", provider: "claude", item: { type: "assistant_message", text: "ok" } },
new Date("2025-01-01T12:00:02Z"),
);
state = reduceStreamUpdate(state, { type: "turn_completed", provider: "claude" }, completedAt);
assert.deepStrictEqual(
state.map((item) => item.kind),
["assistant_message"],
);
});
it("hydrates canonical timeline rows without synthetic turn rows", () => {
const state = hydrateStreamState([
{
event: {
type: "timeline",
provider: "claude",
item: { type: "user_message", text: "hi" },
},
timestamp: new Date("2025-01-01T13:00:00Z"),
},
{
event: assistantTimeline("Working on it.", "claude", "msg-1"),
timestamp: new Date("2025-01-01T13:00:01Z"),
},
{
event: assistantTimeline("Done.", "claude", "msg-2"),
timestamp: new Date("2025-01-01T13:00:04Z"),
},
]);
assert.deepStrictEqual(
state.map((item) => item.kind),
["user_message", "assistant_message", "assistant_message"],
);
});
it("does not materialize turn_started events during hydration", () => {
const startedAt = new Date("2025-01-01T14:00:00Z");
const state = hydrateStreamState([
{
event: {
type: "timeline",
provider: "claude",
item: { type: "user_message", text: "hi" },
},
timestamp: new Date("2025-01-01T13:59:59Z"),
},
{ event: { type: "turn_started", provider: "claude" }, timestamp: startedAt },
{
event: assistantTimeline("ok", "claude", "msg-1"),
timestamp: new Date("2025-01-01T14:00:02Z"),
},
]);
assert.deepStrictEqual(
state.map((item) => item.kind),
["user_message", "assistant_message"],
);
});
it("keeps adjacent user messages as adjacent timeline rows", () => {
const state = hydrateStreamState([
{
event: {
type: "timeline",
provider: "claude",
item: { type: "user_message", text: "hi" },
},
timestamp: new Date("2025-01-01T15:00:00Z"),
},
{
event: {
type: "timeline",
provider: "claude",
item: { type: "user_message", text: "still there?" },
},
timestamp: new Date("2025-01-01T15:01:00Z"),
},
]);
assert.deepStrictEqual(
state.map((item) => item.kind),
["user_message", "user_message"],
);
});
});

View File

@@ -66,6 +66,7 @@ export interface UserMessageItem {
export interface AssistantMessageItem {
kind: "assistant_message";
id: string;
messageId?: string;
text: string;
timestamp: Date;
blockGroupId?: string;
@@ -227,6 +228,7 @@ function appendAssistantMessage(
text: string,
timestamp: Date,
source: StreamUpdateSource,
messageId?: string,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
@@ -234,7 +236,11 @@ function appendAssistantMessage(
}
const last = state[state.length - 1];
if (last && last.kind === "assistant_message") {
const shouldAppendToLast =
last &&
last.kind === "assistant_message" &&
(messageId === undefined || last.messageId === messageId);
if (shouldAppendToLast) {
const updated: AssistantMessageItem = {
...last,
text: `${last.text}${chunk}`,
@@ -249,7 +255,8 @@ function appendAssistantMessage(
if (
source === "live" &&
last?.kind === "user_message" &&
secondLast?.kind === "assistant_message"
secondLast?.kind === "assistant_message" &&
(messageId === undefined || secondLast.messageId === messageId)
) {
const updated: AssistantMessageItem = {
...secondLast,
@@ -264,9 +271,11 @@ function appendAssistantMessage(
}
const idSeed = chunk.trim() || chunk;
const entryId = messageId ?? createUniqueTimelineId(state, "assistant", idSeed, timestamp);
const item: AssistantMessageItem = {
kind: "assistant_message",
id: createUniqueTimelineId(state, "assistant", idSeed, timestamp),
id: entryId,
...(messageId ? { messageId } : {}),
text: chunk,
timestamp,
};
@@ -647,7 +656,9 @@ function reduceTimelineEvent(
case "user_message":
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
case "assistant_message":
return finalizeActiveThoughts(appendAssistantMessage(state, item.text, timestamp, source));
return finalizeActiveThoughts(
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
);
case "reasoning":
return appendThought(state, item.text, timestamp);
case "tool_call":
@@ -710,7 +721,10 @@ export function reduceStreamUpdate(
* Hydrate stream state from a batch of AgentManager stream events
*/
export function hydrateStreamState(
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>,
events: Array<{
event: AgentStreamEventPayload;
timestamp: Date;
}>,
options?: { source?: StreamUpdateSource },
): StreamItem[] {
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp }) => {
@@ -738,6 +752,16 @@ const STREAM_COMPLETION_EVENTS = new Set<AgentStreamEventPayload["type"]>([
"turn_canceled",
]);
function applyCompletionToTail(
tail: StreamItem[],
event: AgentStreamEventPayload,
timestamp: Date,
source: StreamUpdateSource,
): StreamItem[] {
const finalized = finalizeActiveThoughts(tail);
return reduceStreamUpdate(finalized, event, timestamp, { source });
}
/**
* Determine what kind of StreamItem an event would produce
*/
@@ -802,6 +826,28 @@ function getActiveAssistantHeadIndex(head: StreamItem[]): number {
return -1;
}
function getTailAssistantToResume(params: {
incomingKind: StreamItem["kind"] | null;
event: AgentStreamEventPayload;
nextHead: StreamItem[];
tailAssistant: StreamItem | undefined;
}): AssistantMessageItem | null {
if (params.incomingKind !== "assistant_message" || params.nextHead.length !== 0) {
return null;
}
if (params.tailAssistant?.kind !== "assistant_message") {
return null;
}
const incomingMessageId =
params.event.type === "timeline" && params.event.item.type === "assistant_message"
? params.event.item.messageId
: undefined;
if (incomingMessageId !== undefined && params.tailAssistant.messageId !== incomingMessageId) {
return null;
}
return params.tailAssistant;
}
function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: StreamItem[] }): {
tail: StreamItem[];
head: StreamItem[];
@@ -975,8 +1021,7 @@ export function applyStreamEvent(params: {
// Handle turn completion events - flush everything
if (STREAM_COMPLETION_EVENTS.has(event.type)) {
flushHead();
// Also finalize any remaining thoughts in tail
const finalized = finalizeActiveThoughts(nextTail);
const finalized = applyCompletionToTail(nextTail, event, timestamp, source);
if (finalized !== nextTail) {
nextTail = finalized;
changedTail = true;
@@ -991,14 +1036,17 @@ export function applyStreamEvent(params: {
flushHead();
}
if (incomingKind === "assistant_message" && nextHead.length === 0) {
const tailAssistant = nextTail.at(-1);
if (tailAssistant?.kind === "assistant_message") {
nextTail = nextTail.slice(0, -1);
nextHead = [tailAssistant];
changedTail = true;
changedHead = true;
}
const tailAssistant = getTailAssistantToResume({
incomingKind,
event,
nextHead,
tailAssistant: nextTail.at(-1),
});
if (tailAssistant) {
nextTail = nextTail.slice(0, -1);
nextHead = [tailAssistant];
changedTail = true;
changedHead = true;
}
// For streamable kinds, apply to head

View File

@@ -0,0 +1,440 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistantFileLinkResolver,
getAssistantFileLinkToken,
type AssistantFileLinkContext,
type DirectorySuggestionResult,
} from "./assistant-file-link-resolver";
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 };
}
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 });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
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 });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(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 });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(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 });
const second = await resolver.open({ context: CONTEXT, source });
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,
});
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 });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith({
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
});
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" },
});
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" },
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
});
expect(result.opened).toBe(true);
});
it("resolves inline-code basename line refs through directory suggestions", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([
{ path: "packages/server/src/server/workspace-git-service.ts", kind: "file" },
]),
);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "workspace-git-service.ts:1553",
text: "workspace-git-service.ts:1553",
sourceType: "inline-code",
},
});
expect(suggestions).toHaveBeenCalledWith({
query: "workspace-git-service.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "workspace-git-service.ts:1553",
path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts",
lineStart: 1553,
lineEnd: undefined,
});
expect(result.opened).toBe(true);
});
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" },
});
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" },
});
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" },
});
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" },
});
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" },
});
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,258 @@
import {
classifyAssistantFileLink,
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./inline-path";
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;
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkInteraction): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkInteraction {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
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: AssistantFileLinkInteraction): 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") {
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);
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: AssistantFileLinkInteraction,
): 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,
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 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,130 @@
import { describe, expect, it } from "vitest";
import { extractGithubRefs, normalizeGithubRemote, parseGithubRef } from "./github-refs";
const httpsRemote = "https://github.com/getpaseo/paseo.git";
const sshRemote = "git@github.com:getpaseo/paseo.git";
describe("normalizeGithubRemote", () => {
it.each([
["https://github.com/getpaseo/paseo", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
[
"https://github.com/getpaseo/paseo.git",
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
],
["git@github.com:getpaseo/paseo.git", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
[
"ssh://git@github.com/getpaseo/paseo.git",
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
],
])("extracts GitHub identity from %s", (remoteUrl, expected) => {
expect(normalizeGithubRemote(remoteUrl)).toEqual(expected);
});
it("returns null for non-GitHub remotes and empty input", () => {
expect(normalizeGithubRemote("git@gitlab.com:getpaseo/paseo.git")).toBeNull();
expect(normalizeGithubRemote(null)).toBeNull();
});
});
describe("parseGithubRef", () => {
it.each([
"https://github.com/getpaseo/paseo/pull/994",
"https://github.com/getpaseo/paseo/pull/994/",
"https://github.com/getpaseo/paseo/pull/994/files",
"https://github.com/getpaseo/paseo/pull/994?diff=split",
"https://github.com/getpaseo/paseo/pull/994#discussion_r123",
])("parses a matching pull request URL: %s", (text) => {
expect(parseGithubRef(text, httpsRemote)).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
it("parses a matching issue URL", () => {
expect(parseGithubRef("https://github.com/getpaseo/paseo/issues/456", httpsRemote)).toEqual({
kind: "issues",
number: 456,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/issues/456",
});
});
it("matches HTTPS pasted URLs against an SSH remote", () => {
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", sshRemote)).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
it("ignores URLs for another owner or repo", () => {
expect(parseGithubRef("https://github.com/other/paseo/pull/994", httpsRemote)).toBeNull();
expect(parseGithubRef("https://github.com/getpaseo/other/pull/994", httpsRemote)).toBeNull();
});
it("returns null for non-GitHub remotes and empty text", () => {
expect(
parseGithubRef(
"https://github.com/getpaseo/paseo/pull/994",
"git@gitlab.com:getpaseo/paseo.git",
),
).toBeNull();
expect(parseGithubRef("", httpsRemote)).toBeNull();
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", null)).toBeNull();
});
it("finds URLs embedded in text and markdown links", () => {
expect(
parseGithubRef(
"See:\n[the PR](https://github.com/getpaseo/paseo/pull/994/files).",
httpsRemote,
),
).toEqual({
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
});
});
});
describe("extractGithubRefs", () => {
it("returns every matching ref deduped by kind and number", () => {
const text = [
"https://github.com/getpaseo/paseo/pull/994",
"https://github.com/getpaseo/paseo/issues/456#issuecomment-1",
"https://github.com/getpaseo/paseo/pull/994/files",
"https://github.com/other/paseo/issues/1",
].join("\n");
expect(extractGithubRefs(text, httpsRemote)).toEqual([
{
kind: "pull",
number: 994,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/pull/994",
},
{
kind: "issues",
number: 456,
owner: "getpaseo",
repo: "paseo",
url: "https://github.com/getpaseo/paseo/issues/456",
},
]);
});
it("returns an empty array for empty text or null remote", () => {
expect(extractGithubRefs("", httpsRemote)).toEqual([]);
expect(extractGithubRefs("https://github.com/getpaseo/paseo/pull/994", null)).toEqual([]);
});
});

View File

@@ -0,0 +1,117 @@
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
export type GithubRefKind = "pull" | "issues";
export interface GithubRemote {
owner: string;
repo: string;
host: "github.com";
}
export interface GithubRef {
kind: GithubRefKind;
number: number;
owner: string;
repo: string;
url: string;
}
interface ParsedGithubUrl {
kind: GithubRefKind;
number: number;
owner: string;
repo: string;
}
const GITHUB_REF_URL_PATTERN =
/https?:\/\/github\.com\/([^/\s<>)\]]+)\/([^/\s<>)\]]+)\/(pull|issues)\/(\d+)(?:[/?#][^\s<>)\]]*)?/giu;
export function normalizeGithubRemote(remoteUrl: string | null | undefined): GithubRemote | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
const identity = parseGitHubRemoteUrl(trimmed);
if (!identity) {
return null;
}
return {
owner: identity.owner,
repo: identity.name,
host: "github.com",
};
}
export function parseGithubRef(
text: string | null | undefined,
remoteUrl: string | null | undefined,
): GithubRef | null {
return extractGithubRefs(text, remoteUrl)[0] ?? null;
}
export function extractGithubRefs(
text: string | null | undefined,
remoteUrl: string | null | undefined,
): GithubRef[] {
const remote = normalizeGithubRemote(remoteUrl);
const body = text?.trim();
if (!remote || !body) {
return [];
}
const refs: GithubRef[] = [];
const seen = new Set<string>();
for (const match of body.matchAll(GITHUB_REF_URL_PATTERN)) {
const parsed = parseGithubUrlMatch(match);
if (!parsed || !matchesRemote(parsed, remote)) {
continue;
}
const dedupeKey = `${parsed.kind}:${parsed.number}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
refs.push({
kind: parsed.kind,
number: parsed.number,
owner: remote.owner,
repo: remote.repo,
url: `https://github.com/${remote.owner}/${remote.repo}/${parsed.kind}/${parsed.number}`,
});
}
return refs;
}
function parseGithubUrlMatch(match: RegExpMatchArray): ParsedGithubUrl | null {
const owner = match[1];
const repo = match[2];
const kind = match[3];
const numberText = match[4];
if (!owner || !repo || !isGithubRefKind(kind) || !numberText) {
return null;
}
const number = Number.parseInt(numberText, 10);
if (!Number.isSafeInteger(number) || number <= 0) {
return null;
}
return { owner, repo, kind, number };
}
function isGithubRefKind(value: string): value is GithubRefKind {
return value === "pull" || value === "issues";
}
function matchesRemote(parsed: ParsedGithubUrl, remote: GithubRemote): boolean {
return (
parsed.owner.toLowerCase() === remote.owner.toLowerCase() &&
parsed.repo.toLowerCase() === remote.repo.toLowerCase()
);
}

View File

@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
export function buildHostNewWorkspaceRoute(
serverId: string,
sourceDirectory: string,
options?: { displayName?: string },
options?: { displayName?: string; projectId?: string },
) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
if (options?.displayName) {
params.set("name", options.displayName);
}
if (options?.projectId) {
params.set("projectId", options.projectId);
}
return `${base}/new?${params.toString()}` as const;
}

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