Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 74b56cbfe8 fix(android): avoid lint memory pressure in store builds 2026-09-24 12:46:02 +02:00
Mohamed Boudra c67b7158b4 chore(release): cut 0.9.2 2026-09-24 12:05:08 +02:00
Mohamed Boudra 91dc927335 docs: add 0.9.2 changelog 2026-09-24 12:01:57 +02:00
paseo-bot[bot] 49f9cec6be Name config.json when the CLI cannot parse it (#5337)
readPersistedConfig, used by paseo daemon config, status, pair, and
set-password, parsed and validated config.json without the error
wrapping loadPersistedConfig applies, so an invalid file surfaced as a
bare SyntaxError or ZodError. Both readers now share one parse step
that names the file and, for schema errors, the field.

Closes #5328

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 10:47:49 +02:00
paseo-bot[bot] e07da55f81 Show Open in editor for a password-protected desktop daemon (#5335)
* fix(daemon): report the local daemon's server id when a password is set

Since #4575, `paseo daemon status` only learned the server id from an
authenticated connection. The desktop app runs that command without the
daemon password, so a password-protected desktop daemon reported no server
id, the app treated it as remote, and hid Open in editor.

The worker now announces its server id with readiness and the supervisor
publishes it in the instance record next to the listen address, so status
reports the running daemon's identity from local state.

Closes #5300

* fix(cli): omit serverId from status until a daemon publishes one

* chore: rerun CI after macOS file observation flake

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 10:00:01 +02:00
paseo-bot[bot] 27d6a71856 fix(daemon): name the cause when a background start fails before logging (#5332)
`paseo daemon start` runs the supervisor with its output discarded, and the
supervisor opens its log only after config.json loads and the PID lock is
held. A failure before that point went to stderr only, so the launcher
pointed at a daemon.log that was never written and showed no cause.

Startup failures now also go to the log path the launcher reports, so the
error message tails the real cause.

Closes #5327

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 09:00:48 +02:00
paseo-bot[bot]andNoah bbf8cce3f7 List the Fable model mapped in Claude settings.json (#5326)
* fix(server): discover ANTHROPIC_DEFAULT_FABLE_MODEL from Claude settings

* Retrigger CI after website dev server startup timeout

* test(server): assert the built-in Fable entry through the fetched catalog

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 07:55:45 +02:00
paseo-bot[bot] 6016ed7059 Reject missing and non-directory paths when an agent creates a local workspace (#5322)
* Reject missing and non-directory paths for local create_workspace

The create_workspace MCP tool with isolation local persisted a workspace
and project for a path that does not exist or is a regular file. The
app's workspace create request already rejects those paths with
"Directory not found"; the tool now applies the same check before
provisioning.

* Make the local create_workspace e2e assertions path-format independent

* Rethrow filesystem errors other than a missing path in local create_workspace

* Express the local create_workspace e2e test through daemon and workspace helpers

* Clean up the offline MCP daemon when its startup fails

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 06:50:41 +02:00
paseo-bot[bot]andJared 8e858f0e35 Keep checked options and the Other answer together on multi-select questions (#5320)
* Keep checked options and the Other answer together on multi-select questions

On a multi-select question card, typing into Other cleared the checked
options, and checking an option after typing dropped the Other text from
state while the field kept showing it, so submit sent the labels alone.

Multi-select now keeps both and submits the checked labels followed by
the Other text, joined with ", ". Single-select still replaces one with
the other, and now clears the Other field on screen when it does.

Closes #4370

Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>

* Query the question card test by role and accessible name

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>
2026-09-24 06:02:37 +02:00
paseo-bot[bot] fbc83613c0 Keep original file names for chat uploads (#5317)
* fix(server): keep original file names for chat uploads

Uploads replaced every character outside ASCII letters, digits, dot,
dash, and space with an underscore, so CJK, Korean, accented, and
parenthesized names reached disk and the agent as underscores.
Replace only characters no file system accepts in a file name, and
shorten names past 255 bytes while keeping the extension.

* test(server): make upload separator test independent of the daemon OS

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 05:01:09 +02:00
paseo-bot[bot] c356394bfa Load config.json saved with a UTF-8 byte order mark (#5315)
* Load config.json saved with a UTF-8 byte order mark

Windows Notepad saves UTF-8 with a BOM. Both config readers passed the
text straight to JSON.parse, so the daemon refused to start and CLI
config commands failed with "Unexpected token".

Closes #4683

* Strip the config BOM with an anchored replace

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 03:48:52 +02:00
paseo-bot[bot] e6085c1e98 Point CLI auth failures at PASEO_PASSWORD instead of daemon start (#5310)
A password-protected daemon that rejects the CLI is running, so the
start hint was the wrong remedy for AUTH_REQUIRED and AUTH_FAILED.

Fixes #4508

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 02:53:02 +02:00
paseo-bot[bot] 1bf5312297 Start the daemon when an empty paseo.pid was left behind (#5306)
A supervisor killed between creating paseo.pid and writing its contents
leaves an empty file. Every later start, status, and stop then failed
with "Cannot read daemon state", so the daemon stayed down until someone
deleted the file by hand.

An empty lock names no owner, so after the existing read retries it now
reads as no lock, and acquiring the lock removes it before creating a new
one. A lock with unparseable contents still refuses, since it may belong
to a supervisor this version cannot read.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 01:53:13 +02:00
paseo-bot[bot] 7fa78244fd fix(cli): emit full permission request ids from permit ls, allow, and deny (#5305)
The permit result items stored the request id already shortened to eight
characters for the table, so JSON, YAML, and quiet output serialized that
display value. Every request id starts with "permission-", so every row
came out as "permissi" and could not be passed back to permit allow.
The items now keep the full id and only the table column shortens it.

Closes #4732

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 00:52:03 +02:00
paseo-bot[bot] f4efdbded7 Start the daemon when the schedules directory holds a file that is not a schedule (#5301)
* Start the daemon when the schedules directory holds a file that is not a schedule

One unreadable .json file in the schedules directory rejected
ScheduleStore.list(), which ScheduleService.start() awaits during daemon
bootstrap, so the daemon never started. The store now skips a file it
cannot read as a schedule and logs its path once, the way agent storage
treats an invalid agent record.

Closes #4745

* Skip only schedule files with invalid content

A schedule file that cannot be read now fails the listing as before,
instead of being skipped as if it were not a schedule. An invalid file
is reported again when it reappears after being removed or fixed.

* Retrigger CI after an unrelated macOS observation-test flake

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 23:49:18 +02:00
Mohamed Boudra c976e2e5a5 test(server): wait for repository observation before asserting ref refresh (#5303)
* test(server): wait for repository observation before asserting ref refresh

The first checkout facts read can begin before the metadata watcher registers. The test sent an event through an optional watcher lookup at that point and silently dropped it, so a slow setup left only the initial snapshot. Hold the first read to reproduce the ordering and wait for observation setup and the initial refresh before emitting the ref event.

* test(server): isolate ref refresh from background fetch

An asynchronous background fetch could also schedule a structural refresh or populate the known-ref set. Give this test an empty fetch result and wait for that fetch to complete so the second facts read proves the injected watcher event caused it.

* test(server): require the registered repository watcher in the assertion
2026-09-23 23:25:30 +02:00
paseo-bot[bot]andMohamed Boudra b2bb512b31 Let a plugin reload finish without crashing the plugin subprocess (#5231)
* Keep the plugin IPC channel open until provider closes report

closeProviderConnection removed the connection from the registry before
awaiting the provider's close, so shutdown's sweep could not see a close
already in flight. With a provider whose close does real work, the
subprocess finished shutting down and called process.disconnect() while
that close was still running, and the trailing provider.closed send hit a
closed channel and took the subprocess down with an unhandled
ERR_IPC_CHANNEL_CLOSED.

Keep the connection registered until its close has reported, and share
one close promise per connection, so shutdown waits for an in-flight
close instead of disconnecting underneath it. The close now also owns
reporting its own outcome, so it never rejects into teardown.

* Harden the reload teardown test for Windows and clean shutdown

Normalize line endings before patching the example provider, so the
fixture also builds from a CRLF checkout. Wait for the in-flight turn and
archive the agent before stopping the daemon, so teardown no longer
rejects a pending provider request into an unhandled rejection.

* Verify plugin reload teardown and reject sends during close

* Synchronize plugin reload teardown test with close lifecycle

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-23 23:24:53 +02:00
Mohamed Boudra 067937bace Let Hub title workspaces created by its executions (#5302)
* fix(server): let Hub title created workspaces

Agent creation can provision a workspace under hub.execute, but a separate title request required workspace.manage. Permit the same execution authority for title changes and their responses, and lock the Hub request contract with socket and permission tests.

* test(server): keep Hub title repro free of conditional flow
2026-09-23 23:15:06 +02:00
paseo-bot[bot] d615d3d424 Cut the cost of a directory-suggestion scan so Add Project search stops timing out (#5190)
* fix(server): stop re-deriving path state for every scanned directory

Directory suggestion scans were CPU-bound in path bookkeeping rather than
in reading the tree. A 20,000-entry scan of a 197,000-directory home took
6.9s, of which readdir accounted for 50ms; the rest went to repeated path
normalization.

Three redundant passes are removed:

- Discovery re-derived Git-ignore state by walking every ancestor of every
  entry. Traversal only descends through entries that already passed that
  check, so only the entry itself can be newly ignored.
- Discovery re-derived root containment for every child. Only a symlink can
  resolve outside the tree being walked; every other child is its parent's
  path plus a name, and the parent was already proved inside.
- The directory listing cache swept itself for expired keys on every miss
  once it reached its cap, an O(cache size) pass per directory read. Reads
  already reject stale entries by expiry and by directory metadata.

The same scan now takes 1.1s. The new test expresses the budget against a
plain readdir walk of the same tree, so it means the same thing on CI as on
a developer machine: the ratio was 12x and is now under 3x.

Refs #4879

* test(server): skip the scan-cost budget on Windows

The tree it needs is deeper than MAX_PATH allows, and the directory
listing cache it measures is disabled on Windows because directory
metadata cannot validate it there.

* test(server): count containment derivations instead of timing the scan

The previous budget compared wall-clock scan time against a readdir walk
of the same tree. packages/server/AGENTS.md requires tests to be
deterministic, and a ratio between an I/O-bound walk and a CPU-bound scan
moves with filesystem caching and runner load.

isPathInsideRoot now counts its calls behind start/stopPathContainmentMetrics,
matching the existing git command metrics. Scanning a symlink-free tree of
320 entries derived containment 1,760 times before this branch and derives
it none now, which is the defect stated exactly rather than inferred from a
stopwatch.

A second test covers the other direction: a symlink that leaves the root is
still checked, and still excluded from results.

* fix(server): keep the Git-ignore ancestor walk for symlinked entries

A symlink resolves to a path whose ancestors were never checked during
traversal, so a link into an ignored directory was discovered. Named
children keep the single-entry check.

* test(server): declare the scan helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:55:13 +02:00
paseo-bot[bot] fbe5005aa1 Allow Backspace in custom keyboard shortcuts (#5224)
Capture modified Backspace shortcuts in the editor while keeping bare Backspace as the erase-last-combo key. Cover the reported Mac chord, its Windows Ctrl equivalent, and the erase behavior in the desktop renderer suite.

Closes #5215

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:39:00 +02:00
Mohamed Boudra e998e0a082 Stop the daemon holding a CPU core on a repository the watcher can't observe (#5170)
* fix(server): back off degraded git polling on an unchanged workspace

When the recursive watcher declines a repository (over 250,000 entries on
macOS/Windows, or any watcher setup failure), WorkspaceGit falls back to
polling. That fallback rescheduled itself every 5 seconds for as long as the
workspace existed, with no client or agent involved. On a repository large
enough to defeat the watcher each tick is an expensive `git status`, so the
daemon held about a core indefinitely.

Double the gap after every tick whose workspace snapshot came back identical,
up to a 60-second ceiling, and reset to the 5-second base on the first change.
Both degraded loops — working tree and repository metadata — share the rule.
Explicit reads still force a refresh, so the ceiling only bounds how long an
edit made outside Paseo stays undiscovered.

Fixes #5102

* test(server): cover repository-metadata degraded backoff, document failed-tick semantics

* test(server): drive the recovery-refresh assertion off the fake clock, not waitFor

* test(server): declare metadata poll counter as a function
2026-09-23 22:20:17 +02:00
paseo-bot[bot] ae42b0afb0 fix(omp): wait for terminal event after custom messages (#3258)
Custom messages may precede the prompt user message during an active OMP turn. Treating them as completion terminalized the turn while the provider continued working.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:06:53 +02:00
paseo-bot[bot] 829cc5e173 Let Claude sessions pass structured launch arguments (#5206)
Expose Claude Agent SDK's documented extraArgs map through providerOptions so Claude-specific flags such as --chrome reach the CLI.

Document boolean and valued flags, link the upstream contract, and validate through schema tests, the public session interface, and a real Claude launch.
2026-09-23 22:04:20 +02:00
paseo-bot[bot] 48384cafdf Create new branches without an upstream so the first push cannot land on main (#5249)
* fix(server): create new branches without an upstream

A new branch Paseo creates in place ran `git checkout -b <new> <base>`,
which lets git decide the upstream. Git sets `origin/<base>` whenever the
base is a remote-tracking ref (what the default-branch resolver returns
when the local default branch is absent) or when `branch.autoSetupMerge`
is `inherit`. Every later push reads that upstream, so the work lands on
the default branch: `git push -u origin <branch>` under
`push.default=tracking` pushes to `main`, and Paseo's own push resolves
`branch.<name>.merge` and pushes `HEAD:refs/heads/main`.

Pass `--no-track`, the same promise the worktree path already makes with
`git worktree add -b <branch> --no-track <base>`.

* test(server): read the upstream with for-each-ref so a git failure cannot read as absent

* test(server): use a function declaration for the clone fixture git runner

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:03:55 +02:00
paseo-bot[bot]andGurvan Campion 2c7b38bc7e Run OpenCode agents under the permission rules they were given (#5296)
* fix(opencode): set permission rules on the OpenCode session

OpenCode stores permission rules on the session and its prompt_async
request schema has no permission field, so rules built from provider
options and Hub tool grants were dropped on every turn: an agent
configured with external_directory: allow still asked before reading
outside its worktree.

Send the rules where OpenCode reads them — on session.create, and on
session.update when resuming a session Paseo did not just create — and
stop attaching them to prompts. A resume that cannot apply its rules now
fails instead of running under a more permissive policy than configured.

The real-server test is taken from Gurvan Campion's #5295, which reached
the same diagnosis independently.

Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>

* test(opencode): use function declarations for the permission test helpers

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
2026-09-23 21:56:19 +02:00
Mohamed Boudra 222d45a2fc fix(plugin-provider): keep request failures from crashing the daemon (#5298)
Observe provider response rejection before transport acceptance completes, while preserving the original promise and errors returned to callers. Cover early failure, send rejection, closure, cancellation, and successful retries through the public provider interface.

Closes #4781.
2026-09-23 21:54:46 +02:00
Mohamed Boudra 373da70694 Present company sponsorship as support instead of ad space (#5297)
* feat(website): present company sponsorship as support, not ad space

The homepage showed four empty "Sponsor / $500 a month" boxes under a
"New" badge, right below a note about staying independent. They read as
ad inventory for sale. The price now lives only on /sponsor, next to the
checkout. The homepage shows a Sponsors row only once a company has
sponsored, and the READMEs link to /sponsor instead of straight to
Stripe checkout.

* feat(website): drop subtitles from the sponsor options

Each option carried a line like "From $5 a month" or "with an invoice" that tried to set it apart, but the differences are not real: GitHub Sponsors invoices too and any channel takes small amounts. Showing the names lets people pick the platform they already use.
2026-09-23 21:25:09 +02:00
Mohamed BoudraandMarek Rom c3e1e084a0 fix(server): bound watcher work for late ignored directories
Refresh Git exclusions when ignored directories appear after subscription and bound Linux and native classification work. Preserve tracked-file notifications, ignore-update barriers, and watcher recovery.

Fixes #4558. Supersedes #4808 by @Marcus172; this change incorporates six commits originally authored by Marek Rom and adds verified follow-up fixes. The independent session-context leak is fixed by #5208. Combined isolated daemon verification passed.

Co-authored-by: Marek Rom <marek.marcus.rom@gmail.com>
2026-09-23 20:55:30 +02:00
Mohamed Boudra 9978988e35 fix(server): prevent daemon heap growth after sessions close
Keep AsyncLocalStorage registrations fixed across SessionDelivery lifetimes, preserving request isolation and nonblocking close.\n\nVerified by failing-first regression, isolated memory reproduction, and combined daemon OOM verification with #5222.
2026-09-23 20:54:51 +02:00
paseo-bot[bot] 8768500fc0 Stop a replica cache read from spinning when the store rejects its writes (#5290)
* Stop a replica cache read from spinning when the store rejects its writes

A replica cache read waits for accepted commits to reach the row store
before it answers, and retries until the store agrees. When the store
rejects the write deterministically, that retry never makes progress: the
read loops on flush and read with no delay, burning a core and never
returning. The read now fails closed on a rejected write, so callers fall
back to the daemon instead of the loop saturating the renderer.

* Fail a read closed only when its own host has unwritten changes

A rejected write carries whatever was pending across every host, so
giving up the read on any rejection hid stored rows from hosts that had
nothing pending. A host whose rows are already in the store stays
readable while another host's write keeps failing.

* Bound the read count in the storage fake instead of the test body

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 20:45:06 +02:00
paseo-bot[bot] 89073d4c5c Keep a Claude rewind anchor off subagent messages (#5289)
Task subagent frames stream on the same query as the conversation, and
rememberTranscriptProgress recorded any assistant frame as the current turn's
rewind anchor. When a turn's last assistant frame came from a subagent, the
anchor held a uuid that lives on the subagent's sidechain rather than on the
session's own chain, so forkSession rejected it with "Message <uuid> not found
in session" and every later rewind in that conversation failed.

Skip frames that carry a parent_tool_use_id when recording anchors. The
persisted history path already skips sidechain entries; the live stream now
applies the same rule.

Closes #5284

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 19:45:11 +02:00
paseo-bot[bot] ec43e90670 Let a rebound pane-focus shortcut fire while typing (#5287)
The pane-focus defaults carry `editable: false` so that Cmd+Shift+Arrow
keeps selecting text in a field instead of moving pane focus. That guard
describes the default combo, not the action, but `buildEffectiveBindings`
carried it onto a user's override: rebinding focus-pane to Alt+H/J/K/L in
Settings produced a shortcut that fired everywhere except the composer,
and since switching panes focuses that pane's composer, navigation
stopped after one hop.

Drop the guard when an override replaces the combo, the same way the
default display keys are dropped. The default Cmd+Shift+Arrow still
yields to text selection.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:26 +02:00
paseo-bot[bot] 897a0abb1a Keep one copy of the timeline when an agent is refreshed (#5286)
* Clear the committed timeline when an agent refresh rehydrates from disk

Refreshing a loaded agent reloads it with `rehydrateFromDisk`, which wipes
the in-memory timeline so provider history can be re-streamed. It left the
committed timeline in place, and that is what `getTimelineRows` reads, so
every refresh appended a second full copy of the history: N refreshes, N
copies of every message.

Wipe both timelines on that path, the way the force-hydrate path already
does, so the re-streamed history replaces the old rows instead of stacking
on them.

* Replace the committed timeline from the replay instead of wiping it first

Deleting the committed rows on the reload path meant a history replay that
failed halfway left the agent with no transcript, and a retry appended a
fresh replay to whatever partial rows it had written.

Collect the whole replay before touching either store, then drop the rows a
previous hydration committed and write the new ones. A failed stream now
leaves the committed timeline exactly as it was, and this covers the replay
that runs when an agent is loaded after a daemon restart, which duplicated
the same way a refresh did.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:20 +02:00
paseo-bot[bot] c4771ca467 Rewind a Claude conversation past a turn that got no response (#5285)
A turn that dies before the model emits its first token leaves its rewind
anchor without an assistant message id. resolveConversationRewindTarget read
only the anchor immediately before the target, so that one turn made every
later rewind in the conversation throw "assistant response id was not
observed" for good.

Such a turn carries no conversation state, so walk back to the most recent
turn that did answer and fork there, falling back to a fresh session when
none did.

The rewind SDK now reaches the session as an option so the regression test
can drive revertConversation without forking a real Claude session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 17:46:00 +02:00
paseo-bot[bot] 05bfffad41 Keep the voice thinking tone out of the gaps inside a spoken reply (#5281)
Closes #5061
2026-09-23 16:49:10 +02:00
paseo-bot[bot] faee1cd952 Start the daemon after a reboot hands its old PID to another process (#5277)
Closes #5257
2026-09-23 16:49:05 +02:00
paseo-bot[bot]andgengjiawen ba4595d4e8 Start a Cursor agent on a model that has no Fast variant (#5274)
* Start an ACP session when a stored feature value no longer applies

Replaying stored config onto a new ACP session already tolerates a model
the session cannot select, warning and keeping the provider default.
Feature values were the exception: they were written unconditionally, and
either Paseo's own "does not expose ACP feature" guard or the provider's
-32602 rejection escaped out of initializeNewSession, so no agent was
created.

Cursor's `fast` is a per-model option, so switching a draft from a
fast-capable model to one without a fast variant left a stored value the
new session cannot take, and agent creation failed.

Apply stored feature values best-effort at session start, matching the
model path. A user-initiated setFeature still reports the failure.

Co-authored-by: gengjiawen <technicalcute@gmail.com>

* Tolerate a provider's invalid-params only after a model switch

A stale option snapshot is only possible when the replay switched the
model, since the model write answers with an empty response. Without a
switch the options came straight from session/new, so a rejected write
is a real failure the user needs to see.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: gengjiawen <technicalcute@gmail.com>
2026-09-23 15:45:26 +02:00
34c9fd03c9 fix(codex): expose Fast for GPT-6 Sol and GPT-6 Luna (#5273)
The Codex Fast allowlist covered only gpt-6-astra in the GPT-6 family, so
selecting GPT-6 Sol or GPT-6 Luna published no fast_mode feature and the
composer had no Fast control to render. OpenAI documents Fast for Astra,
Sol, and Luna, and the app server's model/list advertises the fast speed
tier for all three.

Add both model IDs to the allowlist and to the parameterized feature test
covering the toggle, the restore path, and the serviceTier sent on turn
start.

The same change was proposed independently in #5220 and #5268.

Closes #5271

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: basilkhowaja <bk08432@st.habib.edu.pk>
2026-09-23 14:46:37 +02:00
paseo-bot[bot] 9a3f3a0dc6 fix(app): complete a multi-step shortcut whose second step holds a modifier (#5272)
A recorded sequence like Ctrl+K Ctrl+J never fired. Pressing a modifier
emits a keydown for the modifier key itself before the combo that holds
it, so a chord waiting on its second step saw a bare `Control` first.
That keydown matches no combo, and the resolver's fall-through reset the
chord to step 0, leaving the following Ctrl+J to resolve as a first step
and match nothing. A second step with no modifier was unaffected, which
is why Ctrl+K J worked.

A modifier keydown is the prefix of a combo, never a combo of its own, so
the resolver now ignores it and leaves the chord where it is. The capture
path already held that rule in `keyboardEventToComboString`; this exports
the predicate behind it rather than adding a second notion of what a
modifier key is.

Closes #5265
2026-09-23 14:46:33 +02:00
paseo-bot[bot]andIvan Malison d6861f81e5 Keep a multi-key shortcut alive when the app re-renders between its keys (#5255)
### Linked issue

Closes #4821

### Type of change

- [x] Bug fix

### Reasoning

A key sequence recorded in Settings, for example `Ctrl+K` then `J`, silently did nothing whenever the app re-rendered between the two keys. Resizing the window, toggling the sidebar, or an agent turn starting or finishing is enough, so a rebound sequence looks broken at random and the second key lands in the composer as text.

The global `keydown`/`keyup`/`blur`/`visibilitychange` listeners, and on desktop the `browser-shortcut-input` subscription, were registered by an effect that depended on eighteen render-scoped values. One of them, `useActiveWorkspaceSelection()`, returns a new object every render, so the effect tore down and re-subscribed on every render of `AppContainer`. Its cleanup resets the in-progress sequence, so the second key was resolved from step 0, matched nothing, and fell through as ordinary input.

The key handlers now read the latest render through `useStableEvent`, so the listener effect re-subscribes only when shortcuts are enabled or disabled. Clearing held badge modifiers when the workspace-index jump binding changes moved to its own effect keyed on that modifier. The cleanup still resets the sequence, which is now correct, because it only runs when shortcuts genuinely go away.

### Goals

- A recorded key sequence completes across a root re-render between its keys.
- The global shortcut listeners are registered once while shortcuts are enabled, instead of on every render.
- A regression spec that fails on the current `main` and passes with this change.

### Non-goals

- `useActiveWorkspaceSelection()` still returns a new object per render. This PR stops the shortcut listeners from depending on that identity, it does not change the hook.
- A sequence whose second step also carries a modifier (`Ctrl+K` `Ctrl+J`) is still cancelled when the modifier is re-pressed, because the bare `Ctrl` keydown does not match the next step. That is a separate defect and is not fixed here.
- The Electron `browser-shortcut-input` path is unchanged in shape and was not exercised in a desktop build.

### QA

New spec `packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts`. It seeds `Ctrl+K J` as the "Toggle command center" binding, opens a workspace, and presses the sequence twice: once with nothing in between, once with a viewport resize settled over two frames between the keys.

Before, on `main`, run twice:

```
✘ multi-key shortcut sequences › a sequence completes when the window resizes between its keys
  expect(getByTestId('command-center-panel')).toBeVisible() failed
```

The control assertion in the same test passed both times, so the sequence itself works and only the re-render breaks it. Instrumenting the hook locally showed the mechanism directly:

```
key Control -> chord step 0
key K       -> chord step 1
effect cleanup at chord step 1      <- the resize re-render
effect setup; changed deps: activeWorkspaceSelection
key j       -> chord step 0, match null
```

After, the same spec passes, and `activeWorkspaceSelection` was the only dependency that changed on those renders.

Also run locally on this branch:

- `packages/app` unit suite: 603 files, 5319 tests, all passing.
- Playwright `browser` project, keyboard and command-center specs: `keyboard-shortcut-sequence`, `empty-pane-keyboard`, `changes-shortcut-placement`, `sidebar-workspace-pin-shortcut`, `workspace-archive-shortcut`, `command-center-agent-controls`. 17 passed.
- `npm run typecheck --workspace=@getpaseo/app`, `oxlint`, and `oxfmt --check` on the changed files: clean.

Web only, in Chromium on Linux. Keyboard shortcuts are off on mobile, and the desktop build was not exercised.

### Supersedes

- [PR #4834 — fix(app): keep multi-key shortcuts alive across root re-renders](https://github.com/getpaseo/paseo/pull/4834) — same fix for the same bug. I diagnosed #4821 independently, reached the same cause, and built on that commit, so its author is credited with a `Co-authored-by` trailer. This PR replaces its spec with one that drives the app through the keyboard only, without patching `window.addEventListener` to count registrations.

### Checklist

- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-23 12:47:26 +02:00
paseo-bot[bot]andMatteo Pietro Dazzi 3054f80050 Show MiniMax usage as unavailable when the account has no token plan (#5258)
### Linked issue

Closes #5247

### Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs

### Reasoning

A user whose MiniMax account has no active token plan opened the Usage page and saw a red **Error** badge on the MiniMax card with a block of raw JSON as its body: `[{"expected":"array","code":"invalid_type","path":["model_remains"],...}]`.

MiniMax reports application-level failures inside an HTTP 200 body — `base_resp.status_code` is non-zero and `model_remains` comes back as `null`. Two things went wrong with that response:

- `MiniMaxQuotaResponseSchema` declared `model_remains` as `z.array(...).optional()`, which admits a *missing* key but not `null`, so `.parse()` threw on a perfectly ordinary no-subscription response.
- `fetchUsage()` only checked `res.ok`, so an API-level error returned with HTTP 200 was never inspected in the first place.

The rejection propagated to `ProviderUsageService`, which stores `reason.message` as the card's error text. For a `ZodError`, that message *is* the serialized issue list — which is how the JSON blob reached the card.

This accepts `null` for `model_remains`, parses `base_resp`, and returns the plain unavailable result when the status code is non-zero, matching how a failed HTTP response is already handled in the same method. The API's own `status_msg` goes to the debug log rather than to the card: an account without a token plan is a normal state, not an error, so it should read as "Unavailable" like an unconfigured provider does — not as a red Error.

### Goals

- A MiniMax account with no active token plan renders as **Unavailable** with no error text.
- `model_remains: null` is accepted wherever it appears, with or without a `base_resp`.
- Application-level errors carried in an HTTP 200 body are detected rather than treated as success.
- Accounts that do have a plan keep rendering their interval and weekly windows unchanged.

### Non-goals

- **Generic error rendering in the Usage card.** The issue also asks that structured errors (ZodError and friends) be mapped to a concise message in the UI. That changes what every provider's card shows on schema drift, so it is a design decision for the maintainer rather than part of this fix. With the producer corrected, no ZodError arises on this path; if another provider's response shape drifts, raw issue JSON can still reach the card. Left open for the maintainer.
- Surfacing MiniMax's `status_msg` in the UI. It is logged at debug instead, so the card does not turn red for a normal no-subscription account.

### QA

Reproduced and verified through the real `ProviderUsageService` with the exact payload from the report, in `packages/server/src/services/quota-fetcher/service.test.ts`.

**Before** — `reports MiniMax accounts with no active token plan as unavailable` fails, and the failure output is the reported symptom verbatim:

```
- Expected                          + Received
-   "status": "unavailable",        +   "status": "error",
-   "error": null,                  +   "error": "[
                                    +   {
                                    +     \"expected\": \"array\",
                                    +     \"code\": \"invalid_type\",
                                    +     \"path\": [ \"model_remains\" ],
                                    +     \"message\": \"Invalid input: expected array, received null\"
                                    +   }
                                    + ]",
```

**After** — the full quota-fetcher suite passes: `Tests 71 passed (71)`.

Three tests were added, two of which are red without the provider change (confirmed by stashing only `minimax.ts`):

- `reports MiniMax accounts with no active token plan as unavailable` — the reported response (`model_remains: null` + `base_resp` 2062) → `status: "unavailable"`, `error: null`.
- `reports a null MiniMax token plan as unavailable even without base_resp` — `model_remains: null` alone.
- `still reads MiniMax windows when base_resp reports success` — guards against the new status check rejecting healthy accounts.

Also run locally: `npm run typecheck --workspace=@getpaseo/server`, `npx oxlint packages/server/src/services/quota-fetcher/`, `npx oxfmt --check packages/server/src/services/quota-fetcher/` — all clean.

No UI change, so no screenshots: with the provider fixed, the card takes its existing "Unavailable" rendering, the same one an unconfigured provider already gets.

### Checklist

- [x] Plugin changes follow the SDK import boundaries (if applicable)
- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

## Supersedes

- [PR #5252 — fix(quota): handle MiniMax accounts with no active token plan (#5247)](https://github.com/getpaseo/paseo/pull/5252) — Same diagnosis and same reported workflow. That PR returns `status: "error"` carrying MiniMax's `status_msg`, which keeps the red Error badge on the card for an account that is simply unsubscribed; this branch returns the plain unavailable result the issue asks for. It also adds cross-provider `ZodError` summarizing in `ProviderUsageService`, which is the design decision left as a non-goal above.

Co-authored-by: Matteo Pietro Dazzi <ilteoood@gmail.com>
2026-09-23 12:46:21 +02:00
paseo-bot[bot] 6d37f7fd9f Stop plugins after the agents they serve are closed (#5253)
* Close agents before stopping the plugins that serve them

A clean daemon shutdown stopped every plugin before it closed any agent.
Stopping a plugin withdraws its provider registrations, which pushes an
empty registration set through the agent provider registry, shuts the
plugin provider client down, and closes each still-open session with
`connectionClosed()`. That is reported as `session.runtime_failed` and
translated to `turn_failed`, so an agent whose last turn had completed
successfully was persisted with `lastError: "Provider connection closed"`
and came back after a restart carrying a failure it never had.

Stop tracking plugin provider registrations first so plugin teardown can
no longer withdraw a provider from under an open agent, and stop the
plugins themselves after `closeAllAgents()` and the provider runtime
shutdown have already made every session terminal. Closing the sessions
while their plugin is still running also means `session.close` now
reaches the provider.

Fixes #4877

* Bound each agent close so a silent provider cannot stall shutdown

Closing an agent asks its provider to close the session and waits for the
answer, and that request is only rejected when the provider runtime shuts
down. With agents now closing before the runtime does, a provider that
never answers `session.close` would leave `closeAllAgents()` waiting with
nothing left to unblock it.

Give each close a deadline. Whatever outlives it is abandoned and logged,
and the provider runtime shutdown that runs next rejects the request that
was still pending.

Also type the test fixture's `send` input as `ProviderInput`.

* Move the shutdown tests into the plugin lifecycle suite

Both tests exercise plugin lifecycle against a real daemon, which
lifecycle.e2e.test.ts already covers, so they go there rather than into a
suite of their own.

Drop the wall-clock assertion on how long stop() took. What the test
establishes is that stop() returns at all, and the suite timeout is what
enforces that; measuring elapsed time only added a second bound that a
loaded machine could trip.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 11:47:59 +02:00
paseo-bot[bot] 94ab368e4e Give daemon-spawned ACP terminals the agent's identity (#5248)
* fix(acp): give daemon-spawned terminals the agent's launch environment

A terminal the daemon opens for an ACP agent is a sibling of the agent
process, not a child, so it inherits nothing from it. createTerminal built
its environment from the requested terminal env alone, while the transport
spawn a hundred lines down already overlays the session's launchEnv. The
terminal therefore ran without PASEO_AGENT_ID or PASEO_AGENT_CWD, so
`paseo heartbeat create` refused to run and `paseo run` lost the caller,
creating child agents with no parent. When the daemon itself was started
from inside another Paseo agent, the terminal saw that agent's id instead.

Overlay launchEnv beneath the requested terminal env in both the argv and
the single-string shell branch, matching the transport spawn's precedence.

Fixes #5016

* test(acp): take createSession options as an object

The helper had grown a second optional positional parameter, making
callers read createSession(undefined, launchEnv).

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 09:44:02 +02:00
paseo-bot[bot] d161b5987d Dismiss an Android bottom sheet with Back instead of the screen (#5245)
* Dismiss an Android bottom sheet with Back instead of the screen

Bottom sheets render through a portal inside the app's own view tree
rather than a native modal window, so Android offered Back to the
navigator underneath them. On the model selector that meant one Back
press left the chat and the app, with the sheet still open.

`IsolatedBottomSheetModal` now claims `hardwareBackPress` for as long as
the sheet is on screen and dismisses itself instead. Listeners are
offered the press newest-first, so a stacked sheet answers before the one
below it, and Back falls back through to the navigator once no sheet is
left.

* Move the Back press onto the sheet visibility tracker

The first version held the press in `IsolatedBottomSheetModal` with its own
`isOnScreen` state and a merged ref, duplicating the phase the visibility
tracker already keeps, and it could only be tested through JSDOM with
`vi.mock` and `vi.hoisted`, which docs/testing.md rules out.

The tracker already owns presentation state and the sheet controller, so it
takes a `BackPressSource` port and holds the press for exactly the window it
is in the `presented` phase. `IsolatedBottomSheetModal` goes back to what it
was on main. Every sheet call site reaches the tracker through
`useIsolatedBottomSheetVisibility`, so coverage is unchanged.

The real source registers nothing off Android, where there is no such press
and `react-native-web`'s `BackHandler` logs an error on subscribe.

The test is now a plain unit test against a fake adapter, with no mocks,
no JSDOM, and no component mounting.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 08:45:00 +02:00
paseo-bot[bot] 90737e1dec fix(omp): show a stopped OMP turn as canceled, not failed (#5243)
Pressing Stop on an OMP turn could land in the thread as a failed turn
with OMP's interrupt text as the error, because OMP ends the turn with a
terminal response that carries "Interrupted by user" in errorMessage and
"aborted" in stopReason, and completeTurn classified any errorMessage as
a failure.

A terminal response whose stop reason is aborted now ends the turn as
turn_canceled and terminalizes the turn's in-flight tool calls and
subagents, matching what interrupt() already emits when it wins the race
with the provider's teardown.

A provider-idle state check that resolves after the turn already settled
no longer completes that turn, so a stop can no longer emit a second
lifecycle event behind the cancellation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 07:45:48 +02:00
3eaf0be0b2 Send a Claude slash command as the last content block (#5240)
Claude Code expands a slash command only when it is the last content
block of the user message. buildAgentPrompt orders blocks as chat
history, typed text, images, then rendered attachments, so a "/command"
sent alongside a pasted issue link or a screenshot reached the model as
literal text: the command never ran, and the model either called the
Skill tool on its own or answered without it.

toSdkUserMessage now moves the typed block to the end when it parses as
a slash command. Attachments rendered as text always carry a mimeType,
so a quoted "/command" inside chat history is never mistaken for the
invocation. Message order is unchanged when the typed text is not a
slash command.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Joe Corkerton <joecorkerton@users.noreply.github.com>
2026-09-23 06:45:57 +02:00
paseo-bot[bot]andHMWCS 8269a0ea78 Keep approvals with the user in every Codex mode but Auto-review (#5239)
A Codex mode preset named its approvals reviewer only for Auto-review.
Every other mode left the field off the thread/start and turn/start
request, so the Codex app-server fell back to `approvals_reviewer` from
the user's global config, or to whatever reviewer the live thread was
already using. Default Permissions and Read-only then behaved like
Auto-review: escalations were approved by Codex, with no approval card
and no notification.

The reviewer belongs to the Paseo mode, so the preset now always names
it and the request always carries it. An agent with no Paseo mode still
inherits the Codex configuration, unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HMWCS <121323913+HMWCS@users.noreply.github.com>
2026-09-23 05:46:12 +02:00
paseo-bot[bot] 5303313632 Keep an archived worktree restorable after a failed re-import (#5238)
* fix(server): keep a workspace's placement when its directory is gone

Resolving a workspace read the checkout at its cwd and reconciled the
recorded placement against it. A git read answers "not a checkout" for a
plain directory and for a directory that is not there, so an archived
worktree whose directory had been removed came back as kind "directory"
with branch, worktreeRoot and mainRepoRoot null.

That rewrite happens before the import operation runs, and the failure
path only rolls back a workspace the import created, so a failed
re-import left the record erased. Workspace recovery can only recreate a
missing directory while the record still says "worktree" with a branch,
so the archived worktree stopped being restorable from Paseo and
reported workspace_directory_missing instead.

Placement is now reconciled only against a directory that is there. The
absence is resolved once, where the workspace directory is read, and
reconcileWorkspacePlacement refuses to rewrite from it, so the active
refresh path is covered by the same rule. The workspace reconciliation
pass already skipped missing directories for this reason.

Closes #5111

* fix(server): treat a directory lost mid-read as an absence

A worktree removed or unmounted while the git read is in flight makes
that read report "not a checkout", which erased the placement the same
way a directory that was already gone did. The read now counts as
evidence only while the directory is there on both sides of it.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 05:46:08 +02:00
paseo-bot[bot]andpaseo-bot[bot] 81ad00790f Let Stop settle an agent whose Pi or OMP runtime has exited (#5235)
* Let Stop settle an agent whose Pi or OMP runtime has exited

AgentSession.interrupt() is documented to reject "only when foreground
ownership is still uncertain", and to be a successful no-op when the
session is already idle. The Pi and OMP sessions broke that: interrupt()
issues `abort` (and `clear_queue` for Pi) over the JSONL RPC transport,
and once the child process is gone every request rejects with
"<name> process is closed".

AgentManager reads that rejection as "the provider still owns the turn",
returns `refused`, and leaves the tracked run in place. The user gets
"Cannot stop agent <id> because its active run cancellation was not
acknowledged", the agent stays pinned at `running`, and pressing Stop
again repeats it forever — only a daemon restart clears it.

A dead runtime owns nothing: no turn is running and no prompt is queued,
which is exactly the postcondition abort and clear_queue promise. Route
both through JsonlRpcProcess.requestStopWork, which resolves when the
process is gone and still surfaces real failures from a live process.
The check covers the process dying while the request is in flight, so a
race cannot reintroduce the wedge.

* Require an observed child exit before treating stop work as done

`disposed` marks the transport finished, not the child gone: `close()`
disposes before termination completes, a spawn error disposes without
touching the child, and a child can outlive SIGKILL. Keying
requestStopWork on it could report a turn canceled while the child was
still working.

Track the child's exit event instead, and resolve only on that. A
disposed transport whose child has not exited still rejects, so the
caller keeps refusing a cancellation it cannot prove.

Also move the in-flight tests' command detection into named
`exitOnCommand` helpers, and reuse the extracted `onOmpCommand` for
`replyToCommands` so the OMP file matches the Pi one.

* Let a stop survive an stdin failure that precedes the child's exit

A dying child breaks its stdin pipe a fraction before it emits `exit`.
The broken pipe fails the in-flight abort and starts a termination, and
deciding at that moment saw `exited` still false, so the stop was
refused for a runtime that was already on its way out. That is the
reported wedge again, reached through a narrower race.

close() now records the termination it starts, and requestStopWork waits
for it before deciding. A child that goes on to exit resolves the stop; a
child that outlives SIGKILL still rejects it.

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-23 04:45:08 +02:00
paseo-bot[bot] 290306fd16 Read an archived agent's logs after its worktree is removed (#5229)
* Read an archived agent's history without its working directory

Loading an archived agent for history rejected when its working
directory no longer existed, so `paseo logs <archived-agent>` and
`get_agent_activity` failed with "Working directory does not exist"
once the workspace archive removed the worktree the agent ran in.

Resuming a persisted session already distinguishes an interactive
resume from a history load, but residency was settled after the
session config was prepared, so the directory check ran for both.
Settle residency first and require the directory only for a session
that will actually run in it.

* Assert the archived transcript is readable, not just loadable

* Let archived timeline reads succeed without resurrecting the workspace

The auto-archive test asserted that history reads for an archived agent
in a removed worktree are rejected. That was the old behavior, not the
invariant its comment describes. Assert the invariant instead: the
reads succeed, the removed worktree stays removed, no workspace
observation comes back, and the next agent lifecycle is unaffected.

* Archive the agent only after its turn is finalized

The regression test dispatched a run and archived immediately. Dispatch
resolves before the provider appends the reply to its history, so the
transcript the test reads back could be written after the archive, and
the read could find nothing.

Wait for the turn to be finalized first. The provider appends the
assistant message before it emits turn_completed, so once the turn is
finalized the transcript is already on disk.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 02:45:28 +02:00
paseo-bot[bot] 3a36cc33c6 Keep workspaces on a disk or share that is not mounted (#5227)
* fix(server): keep workspaces whose project root is unreachable

Reconciliation archived every active workspace whose cwd returned ENOENT on
a single stat, including the pass that runs at daemon startup. An external
disk that has not mounted yet, or a network share that is briefly down, made
every workspace on it disappear from the sidebar along with its sessions, and
nothing brought them back when the directory returned.

A missing path is only evidence that a workspace was removed when the project
it belongs to is still readable. When the project root is missing too, the
whole location is unavailable, and projects already persist through that.
Workspaces now follow the same rule.

Closes #4982

* fix(server): read project roots after workspace directories

Reading the project roots first left a window where a volume could go away
between the two reads, marking a project reachable while its workspace looked
missing. Reading them after inverts the skew: a volume that disappears mid-pass
makes the project unreachable, which withholds an archive instead of causing
one.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 00:45:12 +02:00
paseo-bot[bot] 17c5050216 Show a fork checkout's pull request in the workspace (#5221)
* Resolve a fork checkout's pull request by its head repository

A fork checkout's pull request lives in the parent repository, and its
head repository is the only thing that tells it apart from every other
fork's pull request on the same branch name. The per-checkout lookup
asked `gh pr list --repo <parent> --head <forkOwner>:<branch>`, which
`gh` cannot answer: it forwards the whole value as a head branch name,
so the query always came back empty. Every synchronous refresh then
overwrote the status the background poll had resolved, and the workspace
showed no pull request.

Fork checkouts now resolve through the REST pulls endpoint, the one
lookup that takes a head repository, and read each resulting pull
request through the ordinary view path. That also removes the
disambiguation the bare branch name would have needed: `--head main`
already returns thirty pull requests upstream, far past the ten-item
candidate page, so a fork checkout on a common branch name would still
have lost its own pull request.

The three existing fork tests asserted the unanswerable invocation while
their fake runner answered it regardless of the head value. They now run
against a runner that matches head refs the way `gh` does.

* Let a failed fork lookup surface, and keep the checked-out attempt

Two corrections to the fork pull request lookup.

An operational failure on the REST lookup, a rate limit, a timeout, a
DNS failure, was caught and returned as an empty list, so a refresh
reported "no pull request" for a checkout that has one. The lookup now
only handles what it can act on and lets the rest reach the caller, the
same way the non-fork list path does.

A fork branch reused for several attempts can carry the checkout's head
commit on an older pull request than the three the lookup reads. The
page is now requested in full and the attempts carrying the checked-out
commit are read first, so the bound on full reads no longer decides
which attempt the workspace can see.

* Use a function declaration for the head-commit predicate

* Use a function declaration for the test pull request helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-22 23:44:49 +02:00
morvenandMohamed Boudra 5db3bc82fd fix(app): reattach workspace labels when a connection is restored (#5079)
* fix(app): reattach workspace labels when a connection is restored

connectionChanged() drops the workspace label replica on every connection
change but never puts it back: the only caller of connectWorkspaceLabels()
is refreshAll(), which is one of the two branches of requestDemandRefresh().
Both branches mark the connection epoch as satisfied, so when route-only
demand refreshes first, the later full-directory demand early-returns and the
label channel stays detached for the whole connection.

The picker then renders rows merged from other online hosts, every row
disabled and no explanation, since the offline case has no MenuHint.

Attach the replica where the subscriptions are restored, so it no longer
depends on which demand branch wins the epoch.

* fix(app): keep the label replica attached across refreshes

Reattaching on every online connection meant refreshAll() could reconnect a
replica that was already attached, and connect() begins by disconnecting,
which publishes an offline status. The picker disables every row while a host
is offline, so a plain refresh made the menu flicker disabled.

Reuse the live connection when the same client is already attached and just
refresh the catalog. Cover both the flicker and the reconnect path, which is
the case the fix is named after.

* test(app): verify workspace labels after route refresh

The route-first regression must expose the restored label catalog used by sidebar chips, and the test client must accept the directory query passed by the public observer.

* fix(app): show label catalog load errors in picker

An attachment failure reaches the label host snapshot, but the picker only showed mutation errors. Surface the host error beside the disabled or empty label list so the failure is visible in the affected workspace.

* test(app): keep label attachment coverage in directory suite

The directory fake already owns the connection and demand setup. Reuse it for the route-first, full-demand, refresh, and reconnect cases so this regression follows the existing suite and cleanup.

* fix(app): localize label catalog load errors in picker

The host snapshot retains diagnostic details, while the workspace picker gives users the translated catalog-load message.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-22 23:38:50 +02:00
Mohamed Boudra 3dbbbb535e Sponsor page rework with paid homepage sponsor spots (#5219)
* feat(website): rework sponsorship with homepage sponsor spots

The founder note now states how the project is funded (sponsorship and
Paseo Hub) instead of describing the product, and the same note and
backing links render on the homepage and /sponsor from one module so the
two cannot drift.

Companies can sponsor one of four homepage logo spots at $500 a month
through Stripe rather than GitHub Sponsors, because they need invoices
and a vendor relationship. The spots render as placeholders until sold;
approving a sponsor is an entry in src/data/sponsors.ts. The checkout
URL is a placeholder until the Stripe Payment Link exists.

* feat(website): point sponsor spot checkout at the Stripe payment link

* docs(readme): add a sponsors section

* docs(readme): add the sponsors section to the translated READMEs

* docs(website): document the sponsor approval and Stripe cap workflow
2026-09-22 22:54:11 +02:00
a3fc59e818 Keep a project in the sidebar when its folder name ends in a space (#5205)
* fix(app): keep sidebar order keys that end in whitespace

A project view key embeds the project's path, so a directory whose name
ends in a space produces a key that ends in a space. The sidebar order
store trimmed every key on write, so the stored key could never match
the key the sidebar looks up. computeSidebarOrderUpdates saw the project
as missing from the order on every pass, appended it, and the store
trimmed it away again, setting a fresh array each time. The effect that
applies those updates re-ran on every render until React gave up with
error #185, and the app failed to load until the folder was renamed.

Keep keys verbatim on the runtime setters, dropping only blank keys and
duplicates. Migration of persisted state still trims, where a stray
space is an artifact of an older format rather than part of the key.

Fixes #4880

* test(app): give AsyncStorage a real in-memory stub instead of mocking it

`docs/testing.md` bans `vi.mock`, and the test added here mocked
`@react-native-async-storage/async-storage` only because the module has no
Node implementation: its default export reaches for the native module, so
every write from the persist middleware rejects and a test that touches a
persisted store drowns in unhandled rejections.

That is exactly the case `test-stubs/` exists for, reached through the
resolve.alias in `vitest.config.ts` the same way react-native-unistyles,
react-native-svg and expo-linking are. The stub is a real in-memory
key-value store with the same contract, so a test can read back what a
store wrote rather than inspect a recording double.

`sidebar-view-store.test.ts` carried the same mock for the same reason and
no longer needs it either.

* test(app): give the AsyncStorage stub the multi-key calls the app uses

`provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove`
during reconciliation and cleanup. The stub stopped at the single-key calls,
which is fine until a test reaches the provider cache and then fails with
`storage.multiGet is not a function`.

`multiSet` comes along with them so the three multi-key calls behave as a set
rather than leaving the next caller to notice the gap the same way.

* test(app): cover a project folder name ending in a space end to end

Adds a browser regression for #4880: the daemon accepts a directory whose
name ends in a space, and the sidebar has to render its project row instead
of tearing the app down with "Maximum update depth exceeded" on every load.

* test(app): move the trailing-space spec setup behind a seeded-project helper

docs/testing.md asks for E2E bodies that read like plain English with the
setup in helpers, and for helpers that own the project record until cleanup.
The spec did its directory creation, daemon seeding, and removal inline.

seedProjectNamedWithTrailingSpace owns the directory and the daemon project
and returns a cleanup, the same shape seedWorkspace returns, which cannot be
used here because mkdtemp appends the random suffix and so cannot produce a
name that ends in a space. The body is now goto, assert the row, assert no
error screen.

The row is still located by its sidebar-project-row test id, which is how
every other sidebar spec and the shared sidebar helpers address a project row.

---------

Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-22 22:06:24 +02:00
paseo-ai[bot] bc5bc969c8 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 18:44:27 +00:00
Mohamed Boudra 8186585201 chore(release): cut 0.9.1 2026-09-22 20:34:13 +02:00
Mohamed Boudra eb665a55a8 docs(changelog): add 0.9.1 entry 2026-09-22 20:30:04 +02:00
Mohamed Boudra d22737c77a chore: keep heap snapshots out of git
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
2026-09-22 20:30:04 +02:00
aeb98f813a feat(claude): add Opus 5.5 with its model capabilities (#5200)
feat(claude): add Opus 5.5 with its model capabilities

Combine the catalog and runtime identity work from #5191 with the
per-model effort default from #5197. Claude Code 2.1.280 introduces
Opus 5.5 with 1M context, medium effort, and always-on thinking.
Resolve prefixed minor model releases before their major version so
Opus 5.5 retains its identity and capabilities after a turn.

Co-authored-by: Leonardo G. Urci <leonardoguimaraesurci@gmail.com>
Co-authored-by: Sebastian Galindo <seb@jexhq.com>
Co-authored-by: Rodrigo <rodrigo.parisusao25201@gmail.com>
2026-09-22 20:18:00 +02:00
Mohamed BoudraandBag Raul Cristian c8a7667a03 fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.

An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.

The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.

Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
2026-09-22 18:56:05 +02:00
4c051388e1 Reach every Codex conversation in Import session (#5174)
* fix(codex): page through Codex threads when listing importable sessions

Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.

Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.

Closes #5073

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>

* fix(codex): end the thread scan on a page that carries no threads

The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.

Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.

* fix(codex): keep a thread once when the scan sees it on two pages

Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.

Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.

---------

Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-22 17:20:31 +02:00
paseo-ai[bot] 91d9cf1dbd fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 10:36:31 +00:00
Mohamed Boudra 7f7e60bcbb chore(release): cut 0.9.0 2026-09-22 12:25:40 +02:00
Mohamed Boudra 8b6e9447f4 docs(changelog): promote 0.9.0 to stable
Collapses the 0.9.0-beta.1 and 0.9.0-beta.2 entries into one stable entry
covering v0.8.0..83f9fba20. Adds #4413; the other post-beta.2 fixes correct
regressions introduced inside this same range.
2026-09-22 12:20:28 +02:00
Mohamed Boudra 83f9fba209 Keep the draft-create handoff until the new chat is current (#5168)
* fix(app): keep the draft-create handoff until the new chat is current

Creating an agent from the draft composer on web flashed the "Updating
messages" panel toast for 40-200ms. That toast means "this chat may be out
of date, we are catching up" — a chat this client just created cannot be.

The timeline reducer released the create handoff at the first synchronized
page, but the visibility catch-up flag only clears once catch-up settles,
and a fresh chat always has a parked follow-up "after" page because live
rows arrive during the tail fetch. Between the two releases the state
machine saw a hydrated chat with no handoff and a pending catch-up, which
resolves to the resume toast. #4863 turned that branch from silent to
status, which is what made the window visible.

Both signals now flip in one synchronous call: createViewedTimelineSync
gains an onTimelineCurrent port, called from setVisibilityCatchUpReady —
the single place the sync declares a chat current, already covering the
parked follow-up and the fetchLatestTail overflow path. The owner
implements it by clearing the handoff. The optimistic "running" override
therefore lasts one extra round trip.

The state machine and sync-status rules are unchanged; they were correct.

* fix(app): release the create handoff when a chat leaves timeline demand

Closing a new chat's tab before catch-up settled left its sent attempt in
useCreateFlowStore forever. Reopening it in the same session then supplied
optimistic-create continuity for an authoritative chat: the panel showed it
as running and suppressed "Updating messages" while the real catch-up ran —
the state this fix exists to protect. An offline or slow host widens that
window from milliseconds to minutes.

Leaving the demanded set ends the owner's obligation to a chat exactly as
reaching current does, so onTimelineCurrent becomes onCatchUpEnded and fires
on both. It reports after the membership commit, so the sync never describes
itself mid-transition. Disconnect and backgrounding preserve demand and
therefore preserve the handoff.
2026-09-22 11:57:45 +02:00
Mohamed Boudra b2ce2bcb83 Count chat Find matches across the whole chat (#5167)
* fix(app): count chat Find matches across the whole chat

Chat Find searched the whole chat but its counter read "1 of N in
message", where N was the occurrences inside the selected message. The
per-message split is an implementation detail of how the client verifies
and highlights a hit; a reader counting matches is counting the scope
they searched. The counter now reads "3 of 7" for the position across
every message that holds a hit, and Next/Previous walk that sequence,
crossing message boundaries and wrapping at the ends. Per-message
occurrence stays inside the model as navigation state.

The total needs a per-message count for messages that are not on screen,
so the daemon now reports one. It counts with the same matcher the
browser uses on rendered text — query words joined by any whitespace,
case-insensitive, never across a Markdown block boundary — which also
stops it from reporting locations the client would reject. The client
replaces the estimate with the rendered count as each message is
revealed, so the total is exact for everything that has been on screen.

`count` is optional on the wire and tagged COMPAT(timelineSearchCount):
a host that omits it is worth one match per located message until a
reveal corrects it.

* fix(server): stop chat search reporting matches the browser cannot show

Falling back to the whole Markdown source when the per-block scan found
nothing reported a match whenever the query words straddled a block
boundary — "one target" across "target one\n\ntarget two" — and on
syntax the reader never sees. The client always rejected those
locations, so before the whole-chat counter the cost was a wasted round
trip. Now that the total sums the host's per-message counts, a rejected
location inflates the number the reader sees until navigation reaches
that message and drops it. The block scan alone is the whole rule.

The model's Location derives from the wire type again, with count
narrowed to required: the search boundary fills in the COMPAT default,
so the interior never re-checks it.
2026-09-22 10:36:13 +02:00
Mohamed Boudra 135a3b4c9e Find text in assistant messages that streamed in the open chat (#5146)
* fix(app): find text in assistant messages that streamed in the open chat

Chat find failed with the generic "check the host connection" text
whenever the oldest hit sat in a response that streamed while the chat
was open and had more than one Markdown block. Presentation rendered
such a message as one row per block with ids `<messageId>:block:n`,
while hydrated history stayed one row with the raw message id, and every
message-addressed DOM lookup (find reveal, scroll-to-message, history
reveal, the render-cap expansion) only knew the raw id. The reveal step
polled for a row that never existed and gave up after five seconds.

The defect was the dual rendering, so this removes it: every assistant
message is a block group however it arrived, single-paragraph messages
included, so a row id is never a message id. Rows carry
`data-message-id`, `getStreamItemMessageId` is the one way from a row to
its message, and find, scroll-to-message, history reveal and selection
copy address messages through it. Chat find force-mounts every row of
the selected message under virtualization so counts span the whole
message, and pins the chosen occurrence to its row while the scroll
settles. Link reference definitions fold into their neighbouring block
so history never paints an empty row. Find failures now say what went
wrong (connection, history changed, could not show the match) instead
of blaming the connection for everything.

* fix(app): let the Markdown parser decide which blocks are link definitions

A hand-rolled CommonMark regex rejected destinations markdown-it accepts,
such as escaped spaces, so those definitions still became their own empty
row and the neighbouring paragraph lost its link. The block splitter now
parses the block with the same parser: no tokens plus registered
references means definitions only. The two new chat find journeys move
their mechanics into named helpers so the test bodies read as intent.
2026-09-21 22:31:08 +02:00
Mohamed Boudra 2c8e8a8268 fix(app): use Command+F for Find on macOS across chat, file, and terminal (#5129)
Control+F moves the caret forward on macOS, so chat Find and file pane Find
were stealing a keystroke the text editor owns. Chat Find listens on
`document` in the capture phase, so the composer received an already-cancelled
Control+F and the caret never moved.

Each Find surface carried its own copy of the shortcut predicate and only the
terminal's was platform-aware. There is now one predicate, `isFindShortcut` in
`@/pane-find`, that takes the platform as a parameter: Command without Control
on macOS, Control without Command elsewhere, no Shift or Alt. Chat Find, file
pane Find, the Find widget's own query box, terminal Find, and the terminal
emulator runtime all call it, and `terminal-find-shortcut.ts` is gone.

App surfaces source the platform from `findShortcutPlatform()`, which wraps the
app's existing `getShortcutOs()`. The predicate stays free of React Native
imports because the terminal emulator runtime is bundled on its own into the
terminal WebView; its default platform and `getShortcutOs()`'s browser branch
now share one user-agent read in `@/utils/mac-user-agent`.

Closes #5097
2026-09-21 12:34:41 +02:00
ee7949ae25 fix(pi): honor per-model thinking configuration (#4413)
Derive Pi thinking options from each model's thinkingLevelMap, read Pi's effective level back after create, resume, set_model and set_thinking_level, surface failed state reads instead of persisting the requested level, and show the thinking control for single-level models.

Closes #4382

Co-authored-by: therainisme <therainisme@qq.com>
Co-authored-by: trim21 <i@trim21.me>
2026-09-21 11:53:52 +02:00
paseo-ai[bot] d636abd7a4 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-18 09:49:37 +00:00
Mohamed Boudra e9d32a17d6 chore(release): cut 0.9.0-beta.2 2026-09-18 11:41:13 +02:00
Mohamed Boudra 0fd1db5748 docs(changelog): add 0.9.0-beta.2 entry
Covers v0.9.0-beta.1..9696f4226. Only #5040 is user-facing; the other
commits in range are CI and Nix hash changes.
2026-09-18 11:36:22 +02:00
Mohamed Boudra 9696f42264 Subscribe only the chats this session opened (#5040)
* fix(app): subscribe only the chats this session opened

Open-chat membership was sourced from the persisted workspace layout, which is
restored from disk at launch and carries a tab for every chat the user has ever
opened on the host. Every member got an authoritative timeline catch-up at
session start, and each of those fetches resumed its agent on the daemon: one
provider session per historical tab, every launch.

Two things followed from each resume. Registration stamped `updatedAt` over the
restored timestamp, and the resumed agent started with no attention because the
resume path never read the stored flag. Both were persisted, so restarting Paseo
rewrote every workspace's last-used time in the sidebar and cleared Ready to
review on chats the user never looked at. The timestamp loss was permanent:
workspace `statusEnteredAt` is re-derived from `updatedAt` on every daemon start.

Keep the open set on the session instead. A chat joins it the first time the user
opens it and stays until its tab closes or the session ends, so hiding, switching
workspaces, evicting a retained view, backgrounding and reconnect all preserve the
subscription, with no recent-agent limit. Layout keeps its release role and loses
its source role.

Harden the producer as well, so a resume from any caller stays inert: registration
adopts restored timestamps rather than stamping now, and resume carries the stored
attention through.

* fix(agents): keep a resumed agent's updatedAt from moving backwards

Dropping the registration stamp exposed a skew the stamp had been erasing.
`extractTimestamps` read `lastActivityAt ?? updatedAt`, but the two diverge:
renaming, labelling, restoring from archive, marking unread, and clearing
attention all move `updatedAt` on an unloaded agent without touching
`lastActivityAt`. Eight of 854 agent records on one dev machine carry that skew,
up to eight days of it.

A resume then handed clients a timestamp older than one they already held, and
`acceptAgentDirectoryUpdate` drops any state update that goes backwards. Marking
a chat unread without opening it, then opening it, left the unread dot stuck in
History, the agent list, Command Center and the tab: the daemon cleared attention
and the client refused the update.

Read both fields and take the newest, which is already the rule
`resolveStoredAgentPayloadUpdatedAt` applies to the list payload. Move that
function beside the other stored-record readers as `resolveStoredAgentUpdatedAt`
so one rule covers both paths.

Also replace the `!options?.updatedAt` inference in `registerSession` with an
explicit `restoring` flag. The inference was correct for all four call sites, but
it made a second meaning out of a field, and the next caller to seed a timestamp
would have opted out of activity stamping silently.

* test(app): assert a reloaded layout subscribes only the chat it restores into

No e2e reloaded the page with a populated workspace layout and asserted which
agents the daemon acknowledged, so the regression this branch fixes had no
end-to-end guard. Opening four chats across two workspaces leaves a tab per chat
in persisted layout; after a reload only the restored chat may be subscribed,
because subscribing resumes that agent on the daemon.

Verified it fails when session-context's layout callback is pointed back at
replaceVisibleAgentIds: the three restored tabs nobody opened join the
acknowledged set.
2026-09-18 09:12:43 +00:00
Mohamed Boudra 3cc4ae2866 ci(desktop): retry release asset uploads
uploads.github.com returned HTTP 500 five times during the v0.9.0-beta.1
release, each costing a ~50 minute rebuild. The upload step passed every
artifact to one gh invocation, so a single failed asset discarded the rest
and failed the job.

Upload each asset separately with bounded retries, shared by the macOS,
Linux and Windows jobs.
2026-09-17 22:50:13 +02:00
Mohamed Boudra 0e965bcd7a ci(desktop): raise Node heap for macOS release builds
Metro's jest workers OOM transforming the generated ws-outbound.aot.js
validator, the last module in the web bundle. Two consecutive v0.9.0-beta.1
macOS x64 builds died at 5081/5082 modules on that file.

Set max-old-space-size per runner: 8192 on the 14 GB Intel runner, 4096 on
the 7 GB arm64 runner.
2026-09-17 21:58:02 +02:00
paseo-ai[bot] 2f3272490e fix: update lockfile signatures and Nix hash [skip ci] 2026-09-17 17:49:56 +00:00
261 changed files with 12427 additions and 1752 deletions
+32 -1
View File
@@ -52,7 +52,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
format:
name: format
@@ -180,6 +180,37 @@ jobs:
runs-on: windows-latest
steps: *server_test_steps
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
# runner that exercises it. macOS — the platform the reported watcher bug was on —
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-14, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server dependencies
run: npm run build:server-deps
- name: Run file observation tests
run: |
npm run test:unit --workspace=@getpaseo/server -- \
src/server/file-observer \
src/server/workspace-git-service.observation.test.ts \
src/server/workspace-git-service.observation.integration.test.ts \
--bail=1
desktop-tests-ubuntu:
name: desktop-tests (ubuntu-latest)
needs: changes
+9 -27
View File
@@ -102,8 +102,10 @@ jobs:
include:
- runner: macos-14
electron_arch: arm64
node_heap_mb: 4096
- runner: macos-15-intel
electron_arch: x64
node_heap_mb: 8192
permissions:
contents: write
packages: read
@@ -161,6 +163,10 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
# the last module in the web bundle. Node's default heap is sized from
# runner RAM, which is not enough on the Intel runner.
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
@@ -185,16 +191,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -294,16 +292,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -400,16 +390,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
+3
View File
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+75 -1
View File
@@ -1,6 +1,78 @@
# Changelog
## 0.9.0-beta.1 - 2026-09-17
## 0.9.2 - 2026-09-24
### Added
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
### Improved
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
### Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
## 0.9.1 - 2026-09-22
### Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0 - 2026-09-22
### Added
@@ -72,6 +144,8 @@
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
+6
View File
@@ -151,6 +151,12 @@ npm run build:server
npm run typecheck
```
## スポンサー
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 関連プロジェクト
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
+6
View File
@@ -161,6 +161,12 @@ npm run build:server
npm run typecheck
```
## 스폰서
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
+6
View File
@@ -193,6 +193,12 @@ npm run build:server
npm run typecheck
```
## Sponsors
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
+6
View File
@@ -151,6 +151,12 @@ npm run build:server
npm run typecheck
```
## 赞助
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 相关项目
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
+7
View File
@@ -164,6 +164,13 @@ A finished workspace can be marked unread after it has been reviewed. The daemon
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
+17 -3
View File
@@ -29,9 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native
blocks and parses only the growing last block during append. Splitting in the reducer discards
paragraph separators and exposes fragments to plugin callbacks, which need the whole source text.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
+1 -1
View File
@@ -714,7 +714,7 @@ Each entry in the `models` array:
### 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`.
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_FABLE_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. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
+3 -3
View File
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+5 -2
View File
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+14 -5
View File
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
This Paseo version accepts these keys:
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
+20 -8
View File
@@ -86,6 +86,13 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
first page leaves a chat this client just created looking hydrated but still catching up, which
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
one; disconnect and backgrounding keep it.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
@@ -166,14 +173,19 @@ replica cache.
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive every open workspace chat plus any visible agent pane. Workspace layout
owns open-chat lifetime, independently of mounted or retained React views. Switching workspaces,
evicting a retained view, and app backgrounding preserve that demand; closing the chat releases it.
Reconnect restores the open set and gives visible chats the first catch-up attempt. Hidden chats
follow when those attempts settle, including failures, so a failed visible chat does not starve
background recovery. Split panes catch up together. Hidden chats update the replica; on web their
retained presentation stays suspended until revealed, on native it keeps rendering. Revealing a
chat reads the current store and preserves its local UI state. There is no recent-agent limit.
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
enters that set the first time the user opens it and leaves when its tab closes or the session
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
subscribing from layout spawns a provider session per historical tab at every launch and stamps
every one of those workspaces as just used (see
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
does not starve background recovery. Split panes catch up together. Hidden chats update the
replica; on web their retained presentation stays suspended until revealed, on native it keeps
rendering. Revealing a chat reads the current store and preserves its local UI state.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,12 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-6+B64UqX8XtVdWFNtg884SIla6FXBOTtNSO0OMFoQfY=
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37262,12 +37262,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/server": "0.9.0-beta.1",
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/server": "0.9.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37514,10 +37514,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/relay": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38697,14 +38697,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38714,8 +38714,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38737,7 +38737,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38764,7 +38764,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38982,16 +38982,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0-beta.1",
"@getpaseo/highlight": "0.9.0-beta.1",
"@getpaseo/plugin": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/relay": "0.9.0-beta.1",
"@getpaseo/client": "0.9.2",
"@getpaseo/highlight": "0.9.2",
"@getpaseo/plugin": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39895,7 +39895,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -38,6 +38,7 @@ import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
import {
recordPanelToasts,
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
@@ -1165,8 +1166,11 @@ test.describe("Agent message submission", () => {
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
test("restores a rejected submission and accepts its retry", async ({
@@ -1,4 +1,4 @@
import type { BrowserContext } from "@playwright/test";
import type { BrowserContext, Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
/**
* The rows of one assistant message. A message renders one row per Markdown block, so
* every locator and every text walk below covers the group, not a single block.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
}
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
itemText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.filter({ hasText: itemText })
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
tag: "code" | "strong",
text: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text })
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let textNode = walker.nextNode();
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
for (const [tag, text] of [
["strong", "strong prose"],
["em", "emphasized prose"],
+279 -21
View File
@@ -1,5 +1,10 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -15,19 +20,41 @@ import {
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
const STREAMED_RESPONSE = [
"First paragraph with alpha-needle and pair-needle inside.",
"Second paragraph with beta-needle and pair-needle inside.",
`Third paragraph ${FILLER}`,
].join("\n\n");
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
// One message far taller than the viewport, so its first and last paragraph rows are
// never in the virtualizer's window at the same time.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
}).join("\n\n");
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function searchChat(page: Page, text: string) {
async function openChatFind(page: Page) {
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
await page.keyboard.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
}
async function searchChat(page: Page, text: string) {
await openChatFind(page);
await query(page).fill(text);
}
async function expectHighlight(page: Page, text: string) {
@@ -71,6 +98,15 @@ async function nextMatch(page: Page) {
async function previousMatch(page: Page) {
await query(page).press("Shift+Enter");
}
async function expectFindStatus(page: Page, search: string, matches: string) {
await query(page).fill(search);
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
}
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
await nextMatch(page);
await expect(status(page)).toHaveText(matches);
await expectHighlight(page, highlighted);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(query(page)).toHaveCount(0);
@@ -103,14 +139,14 @@ test("loads older history through prompt navigation and finds repeated rendered
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
await searchChat(page, "hello world");
await expectHighlight(page, "hello world");
await expect(status(page)).toHaveText("1 of 2 in message");
await expect(status(page)).toHaveText("1 of 90");
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
await nextMatch(page);
await expect(status(page)).toHaveText("2 of 2 in message");
await expect(status(page)).toHaveText("2 of 90");
await expectHighlight(page, "hello world");
await previousMatch(page);
await expect(status(page)).toHaveText("1 of 2 in message");
await expect(status(page)).toHaveText("1 of 90");
await query(page).fill("hello & world");
await expectHighlight(page, "hello & world");
await query(page).fill("a.b");
@@ -123,6 +159,65 @@ test("loads older history through prompt navigation and finds repeated rendered
}
});
// The scope of the search is the whole chat, so the counter is the position across
// every message that holds a hit, and Next walks off the end of one into the next.
const ACROSS_PROMPT = "Find scope-needle in this chat";
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
/** The test id of the message that holds the current match, so it can be watched crossing one. */
async function highlightedMessage(page: Page) {
return page.evaluate(() => {
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
}
async function expectHighlightedMessage(page: Page, testId: string) {
await expect.poll(() => highlightedMessage(page)).toBe(testId);
}
test("counts and steps through every match in the chat, not just the selected message", async ({
page,
}, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-across-",
title: "Chat Find across messages",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
// One hit in the prompt and two in the reply: three across the chat.
await searchChat(page, "scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "user-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await expectNextMatch(page, "3 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
await expectNextMatch(page, "1 of 3", "scope-needle");
await expectHighlightedMessage(page, "user-message");
await previousMatch(page);
await expect(status(page)).toHaveText("3 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -136,7 +231,7 @@ test("rejects invisible source matches and ignores a late reply after closing",
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message")).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "hidden-destination");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("first cell second cell");
@@ -166,7 +261,7 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message")).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await gate.drop();
await expectReconnectingToastVisible(page);
await searchChat(page, "hello world");
@@ -186,6 +281,104 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
}
});
interface StreamedReplyChat extends MockAgentWorkspace {
/** Resolves once the streamed reply has finished arriving. */
replyFinished(): Promise<void>;
}
/**
* Opens the chat first, then streams a multi-block reply into it and waits for a
* later block, so the block holding the early needles is no longer the growing row.
*/
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-live-blocks-",
title: "Chat Find live blocks",
featureValues: {
mockStreamingAssistantResponse: STREAMED_RESPONSE,
mockStreamingAssistantIntervalMs: 60,
},
});
try {
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
timeout: 30_000,
});
return {
...agent,
replyFinished: async () => {
await agent.client.waitForFinish(agent.agentId, 60_000);
},
};
} catch (error) {
await agent.cleanup();
throw error;
}
}
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
test.setTimeout(120_000);
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
// The only hit is in the first paragraph, which is no longer the row that grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
/**
* Opens a chat whose newest reply is a single message far taller than the viewport,
* behind enough older turns that the oldest one is not hydrated yet.
*/
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-virtualized-",
title: "Chat Find virtualized message",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
// Every block row of the selected message must be mounted for Find to see it, whatever
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
// from the count and Next walks off to the next message instead.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
test.setTimeout(120_000);
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
page,
}) => {
@@ -194,7 +387,8 @@ test("finds text beyond the normal render cap and restores the cap when Find clo
repoPrefix: "chat-find-cap-",
title: "Chat Find long message",
initialPrompt: "Render a long response",
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}\n\n${tail}` },
// One paragraph, so the needle is past the render cap of the row that holds it.
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
@@ -212,30 +406,94 @@ test("finds text beyond the normal render cap and restores the cap when Find clo
}
});
for (const shortcut of ["Control+f", "Meta+f"]) {
test(`opens and refocuses Find with ${shortcut} from the composer`, async ({
page,
}, testInfo) => {
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-composer-",
title: "Chat Find from composer",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await searchFromComposerWithoutLosingDraft(page, shortcut);
await refocusFindAndReplaceQuery(page, shortcut);
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
await closeFind(page);
await expectCommandCenterOwnsFindShortcut(page, shortcut);
await fillComposerDraft(page, "Draft after closing Find");
await expectComposerDraft(page, "Draft after closing Find");
} finally {
await agent.cleanup();
}
}
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Control+f", testInfo);
});
test.describe("macOS", () => {
test.use({ userAgent: MAC_USER_AGENT });
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Meta+f", testInfo);
});
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
test("leaves Control+f to the composer", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-composer-",
title: "Chat Find from composer",
repoPrefix: "chat-find-mac-control-f-",
title: "Chat Find macOS Control+f",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await searchFromComposerWithoutLosingDraft(page, shortcut);
await refocusFindAndReplaceQuery(page, shortcut);
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
await closeFind(page);
await expectCommandCenterOwnsFindShortcut(page, shortcut);
await fillComposerDraft(page, "Draft after closing Find");
await expectComposerDraft(page, "Draft after closing Find");
await fillComposerDraft(page, "Keep this draft");
await expectComposerFocused(page);
await recordComposerKeydown(page);
await page.keyboard.press("Control+f");
await expect
.poll(() => recordedComposerFindKeydown(page))
.toEqual([{ key: "f", defaultPrevented: false }]);
await expect(query(page)).toHaveCount(0);
await expectComposerFocused(page);
await expectComposerDraft(page, "Keep this draft");
} finally {
await agent.cleanup();
}
});
});
interface RecordedKeydown {
key: string;
defaultPrevented: boolean;
}
/**
* Chat Find listens on `document` in the capture phase, so the composer only ever
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
* on the composer the evidence of who owns the key.
*/
async function recordComposerKeydown(page: Page) {
await composerLocator(page).evaluate((element) => {
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
store.__findKeydown = [];
element.addEventListener("keydown", (event) => {
store.__findKeydown.push({
key: (event as KeyboardEvent).key,
defaultPrevented: event.defaultPrevented,
});
});
});
}
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
const recorded = await page.evaluate(
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
);
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
}
async function openFindFromComposer(page: Page, shortcut: string) {
@@ -1,4 +1,4 @@
import { test } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
@@ -79,6 +79,27 @@ test.describe("Command Center agent controls", () => {
}
});
test("shows an existing agent's only supported thinking level", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-controls-single-thinking-",
title: "Single thinking level",
model: "max-only-thinking-stream",
thinkingOptionId: "max",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
).toHaveAccessibleName("Select thinking option (Max)");
} finally {
await workspace.cleanup();
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
@@ -0,0 +1,44 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
await page.addInitScript(
([key, bindingId, value]) => {
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
},
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
);
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
}
async function expectCommandCenterOpen(page: Page): Promise<void> {
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
}
test.describe("Multi-step shortcut", () => {
test("completes a chord whose second step is a bare key", async ({ page }) => {
await rebindCommandCenter(page, "Ctrl+K J");
await page.keyboard.press("Control+k");
await page.keyboard.press("j");
await expectCommandCenterOpen(page);
});
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
test("completes a chord whose second step carries a modifier", async ({ page }) => {
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
await page.keyboard.press("Control+k");
await page.keyboard.press("Control+j");
await expectCommandCenterOpen(page);
});
});
@@ -0,0 +1,65 @@
import { expect, test, type Page } from "../support/fixtures";
// "Toggle command center" recorded as a two-key sequence. Both platform
// bindings are overridden so the spec does not depend on the host OS.
const COMMAND_CENTER_SEQUENCE = {
"command-center-toggle-cmd-k-mac": "Cmd+K J",
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
};
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
const SEQUENCE_FINAL_KEY = "j";
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
async function recordCommandCenterSequence(page: Page): Promise<void> {
await page.addInitScript((overrides) => {
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
}, COMMAND_CENTER_SEQUENCE);
}
/**
* Resize the window and let the app render again, so the resize lands between
* the two keys of the sequence rather than after them.
*/
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 800 });
await page.waitForFunction((expected) => window.innerWidth === expected, width);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
async function expectCommandCenterOpen(page: Page): Promise<void> {
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
}
test.describe("Multi-key shortcut sequences", () => {
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
await recordCommandCenterSequence(page);
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
await workspace.navigateTo();
await page.keyboard.press(SEQUENCE_PREFIX);
await page.keyboard.press(SEQUENCE_FINAL_KEY);
await expectCommandCenterOpen(page);
});
test("a sequence completes when the window resizes between its keys", async ({
page,
withWorkspace,
}) => {
await recordCommandCenterSequence(page);
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
await workspace.navigateTo();
await page.keyboard.press(SEQUENCE_PREFIX);
await resizeWindowAndSettle(page, 1100);
await page.keyboard.press(SEQUENCE_FINAL_KEY);
await expectCommandCenterOpen(page);
});
});
+50 -5
View File
@@ -25,14 +25,21 @@ async function findInSource(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
function querySelection(page: Page) {
return query(page).evaluate((input: HTMLInputElement) => [
input.selectionStart,
input.selectionEnd,
]);
}
/** The query box keeps the caret where the user left it, with nothing reselected. */
async function expectQueryCaretKept(page: Page, text: string) {
await expect.poll(() => querySelection(page)).toEqual([text.length, text.length]);
await expect(query(page)).toHaveValue(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
)
.toEqual([0, text.length]);
await expect.poll(() => querySelection(page)).toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
@@ -428,3 +435,41 @@ test("Go to line keeps its dialog with Find closed and open", async ({
await expect(status(page)).toHaveText("11 matches");
});
});
test.describe("macOS", () => {
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
});
// Control+F moves the caret forward on macOS, so Find must only claim Command+F.
test("opens Find with Meta+f and leaves Control+f to text editing", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-mac-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await source(page).focus();
await source(page).press("Control+f");
await expect(query(page)).toHaveCount(0);
await source(page).press("Meta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await test.step("Control+f inside the query box does not reclaim the field", async () => {
await query(page).press("End");
await query(page).press("Control+f");
await expectQueryCaretKept(page, "needle");
});
await page.screenshot({ path: testInfo.outputPath("mac-find.png") });
await query(page).press("Meta+f");
await expectQuerySelected(page, "needle");
await closeFind(page);
});
});
@@ -0,0 +1,77 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { connectSeedClient } from "../support/helpers/seed-client";
interface SeededProject {
projectKey: string;
cleanup(): Promise<void>;
}
/**
* Adds a project whose directory name ends in a space, which `seedWorkspace`
* cannot produce because `mkdtemp` appends the random suffix. The project is
* owned until `cleanup`, which removes the daemon record before the directory.
*/
async function seedProjectNamedWithTrailingSpace(): Promise<SeededProject> {
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-"));
const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `);
await mkdir(directoryPath);
await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n");
const client = await connectSeedClient();
const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true });
try {
const added = await client.addProject(directoryPath);
expect(added.error).toBeNull();
expect(added.project?.projectRootPath).toBe(directoryPath);
const project = added.project;
if (!project?.projectKey) {
throw new Error(`The daemon added ${directoryPath} without a project key`);
}
return {
projectKey: project.projectKey,
cleanup: async () => {
await client.removeProject(project.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await removeParentDirectory().catch(() => undefined);
},
};
} catch (error) {
await client.close().catch(() => undefined);
await removeParentDirectory().catch(() => undefined);
throw error;
}
}
async function expectProjectRow(page: Page, projectKey: string): Promise<void> {
const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
await expect(row).toBeVisible({ timeout: 60_000 });
}
async function expectAppShellRendered(page: Page): Promise<void> {
await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0);
}
// A project view key carries the project's path, so a directory whose name ends
// in a space produces a key that ends in a space. The sidebar order store used
// to trim it on write, which left the reconcile effect writing a new order on
// every render until React tore the app down with "Maximum update depth
// exceeded" on every load (#4880).
test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => {
const project = await seedProjectNamedWithTrailingSpace();
try {
await gotoAppShell(page);
await expectProjectRow(page, project.projectKey);
await expectAppShellRendered(page);
} finally {
await project.cleanup();
}
});
@@ -5,6 +5,7 @@ import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-cl
import { getServerId } from "../support/helpers/server-id";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
@@ -53,9 +54,58 @@ async function seedViewedTimelineScenario(
};
}
interface RestoredLayoutChat {
workspaceId: string;
agentId: string;
title: string;
}
interface RestoredLayoutScenario {
chats: RestoredLayoutChat[];
cleanup(): Promise<void>;
}
/**
* Two workspaces with two chats each. Opening all four leaves the persisted workspace layout
* holding a tab per chat, which is the state a relaunch restores into.
*/
async function seedRestoredLayoutScenario(): Promise<RestoredLayoutScenario> {
const workspaces = await Promise.all([
seedWorkspace({ repoPrefix: "restored-layout-first-" }),
seedWorkspace({ repoPrefix: "restored-layout-second-" }),
]);
try {
const chats = await Promise.all(
workspaces.flatMap((workspace, workspaceIndex) =>
[1, 2].map(async (chatIndex): Promise<RestoredLayoutChat> => {
const title = `Restored chat ${workspaceIndex + 1}-${chatIndex}`;
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
});
return { workspaceId: workspace.workspaceId, agentId: agent.id, title };
}),
),
);
return {
chats,
cleanup: async () => {
for (const workspace of workspaces) await workspace.cleanup();
},
};
} catch (error) {
for (const workspace of workspaces) await workspace.cleanup().catch(() => undefined);
throw error;
}
}
async function openAgent(
page: Page,
scenario: ViewedTimelineScenario,
scenario: { workspaceId: string },
agentId: string,
options: { recordRenders?: boolean } = {},
) {
@@ -205,6 +255,43 @@ async function expectCurrentChatWithoutCatchUp(page: Page, message: string) {
}
test.describe("Viewed agent timelines", () => {
test("a reloaded layout subscribes only the chat it restores into", async ({ page }) => {
test.setTimeout(120_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedRestoredLayoutScenario();
try {
// One document load, then sidebar and tab clicks — the way a user reaches these chats.
// A `page.goto` per chat would restart the app and empty the session's set each time.
const [first, second, third, fourth] = scenario.chats;
await openAgent(page, first!, first!.agentId);
await selectAgent(page, second!.title);
await selectWorkspaceInSidebar(page, third!.workspaceId);
await selectAgent(page, third!.title);
await selectAgent(page, fourth!.title);
await subscriptions.waitForSubscribedAgents(scenario.chats.map((chat) => chat.agentId));
// Relaunch. Layout comes back from disk carrying a tab for every chat above; only the
// one on screen may be subscribed, because subscribing resumes the agent on the daemon.
const restored = scenario.chats.at(-1)!;
const sibling = scenario.chats.at(-2)!;
subscriptions.reset();
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: restored.title, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: sibling.title, exact: true })).toBeVisible();
await subscriptions.waitForSubscribedAgents([restored.agentId]);
// Opening the restored sibling is what adds it, and it adds only itself.
await selectAgent(page, sibling.title);
await subscriptions.waitForSubscribedAgents([restored.agentId, sibling.agentId]);
} finally {
await scenario.cleanup();
}
});
test("open chats stay current after switching beyond five agents", async ({ page }) => {
await withViewedTimelineScenario(async (scenario) => {
await openSevenChats(page, scenario);
@@ -18,6 +18,7 @@ export interface MockAgentOptions {
port?: number;
initialPrompt?: string;
model?: string;
thinkingOptionId?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
@@ -43,6 +44,7 @@ export async function seedMockAgentWorkspace(
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "e2e-fast-stream",
thinkingOptionId: options.thinkingOptionId,
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
@@ -40,6 +40,13 @@ export function observeTimelineSubscriptions(page: Page) {
});
return {
/**
* Forget what the daemon acknowledged so far. Use this before a reload, so a wait that
* follows can only be satisfied by a subscription the reloaded app actually sent.
*/
reset(): void {
acknowledgedAgentIds = null;
},
async waitForSubscribedAgents(
agentIds: string[],
options: TimelineSubscriptionWaitOptions = {},
@@ -111,6 +111,50 @@ export async function expectReconnectingToastGone(
});
}
declare global {
interface Window {
__shownPanelToasts?: string[];
}
}
export interface PanelToastLog {
/** Test ids of every toast that entered the DOM since recording started, in order. */
shown(): Promise<string[]>;
expectNeverShown(testId: string): Promise<void>;
}
/**
* Record every toast that enters the DOM from the next navigation onward. A toast can appear
* and disappear within a few frames, so a settled `toHaveCount(0)` cannot prove one never showed.
*/
export async function recordPanelToasts(page: Page): Promise<PanelToastLog> {
await page.addInitScript(() => {
const shown: string[] = [];
window.__shownPanelToasts = shown;
const record = (node: Element) => {
if (node.getAttribute("role") !== "alert") return;
shown.push(node.getAttribute("data-testid") ?? "");
};
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
record(node);
for (const descendant of node.querySelectorAll('[role="alert"]')) record(descendant);
}
}
}).observe(document, { childList: true, subtree: true });
});
const shown = () => page.evaluate(() => window.__shownPanelToasts ?? []);
return {
shown,
async expectNeverShown(testId: string): Promise<void> {
expect(await shown()).not.toContain(testId);
},
};
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
+2 -1
View File
@@ -19,7 +19,8 @@
"APP_VARIANT": "production"
},
"android": {
"resourceClass": "large"
"resourceClass": "large",
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
},
"production-apk": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.0-beta.1",
"version": "0.9.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -6,3 +6,7 @@ export function ChatFind({ children }: ChatFindProps) {
export function ChatFindExpansion({ children }: ChatFindExpansionProps) {
return children(false);
}
/** Native has no chat find, so no message is ever selected by one. */
export function useChatFindSelectedMessageId(): string | null {
return null;
}
@@ -11,7 +11,7 @@ import {
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { Button } from "@/components/ui/button";
import { usePaneFocus } from "@/panels/pane-context";
import { useRetainedPanelActive } from "@/components/retained-panel";
@@ -19,11 +19,16 @@ import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
import { ChatFindModel } from "./model";
import { ChatFindModel, type ChatFindFailure } from "./model";
import { createFindViewport } from "./viewport.web";
import type { ChatFindProps, ChatFindExpansionProps } from "./types";
const Expansion = createContext<string | null>(null);
const SelectedMessage = createContext<string | null>(null);
const FAILURE_TEXT: Record<ChatFindFailure, string> = {
connection: "paneFind.connectionFailure",
historyChanged: "paneFind.historyChangedFailure",
reveal: "paneFind.revealFailure",
};
const WIDGET_DATASET = { chatFindWidget: "true" };
const ROOT_STYLE = {
display: "flex",
@@ -34,8 +39,17 @@ const ROOT_STYLE = {
} as const;
let nextHighlightId = 0;
export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) {
return children(useContext(Expansion) === itemId);
/**
* The message chat find has landed on, for surfaces that must keep it addressable:
* the row that lifts its render cap, and the web virtualizer that has to mount every
* block row of it so the whole message can be counted and highlighted.
*/
export function useChatFindSelectedMessageId(): string | null {
return useContext(SelectedMessage);
}
export function ChatFindExpansion({ messageId, children }: ChatFindExpansionProps) {
return children(useChatFindSelectedMessageId() === messageId);
}
export function ChatFind({
@@ -44,15 +58,15 @@ export function ChatFind({
epoch,
items,
viewportRef,
revealLoadedItem,
visibleItemIds,
revealLoadedMessage,
visibleMessageIds,
children,
}: ChatFindProps) {
const { t } = useTranslation();
const widget = useRef<PaneFindHandle>(null);
const root = useRef<HTMLDivElement>(null);
const bindings = useRef({ viewportRef, revealLoadedItem, visibleItemIds });
bindings.current = { viewportRef, revealLoadedItem, visibleItemIds };
const bindings = useRef({ viewportRef, revealLoadedMessage, visibleMessageIds });
bindings.current = { viewportRef, revealLoadedMessage, visibleMessageIds };
const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`);
const { isInteractive } = usePaneFocus();
const active = useRetainedPanelActive();
@@ -92,12 +106,7 @@ export function ChatFind({
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
model.open();
widget.current?.focus();
@@ -117,12 +126,12 @@ export function ChatFind({
else if (state.phase === "error") status = t("paneFind.failed");
else if (state.query.trim())
status = state.count
? t("paneFind.chatPosition", { current: state.occurrence + 1, total: state.count })
? t("paneFind.position", { current: state.occurrence + 1, total: state.count })
: t("paneFind.noMatches");
return (
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
<style>{`::highlight(${highlightName}) { background-color: ${styles.highlight.backgroundColor}; color: ${styles.highlight.color}; }`}</style>
<Expansion.Provider value={state.selectedItemId}>{children}</Expansion.Provider>
<SelectedMessage.Provider value={state.selectedItemId}>{children}</SelectedMessage.Provider>
{state.open && (
<View style={styles.overlay} dataSet={WIDGET_DATASET}>
<PaneFind
@@ -135,9 +144,9 @@ export function ChatFind({
onPrevious={model.previous}
onClose={close}
/>
{state.error && (
{state.failure && (
<View style={styles.error}>
<Text style={styles.errorText}>{t("paneFind.searchFailed")}</Text>
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
<Button size="xs" variant="ghost" onPress={model.retry}>
{t("paneFind.retry")}
</Button>
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { hydrateStreamState, type StreamItem } from "@/types/stream";
import { ChatFindModel, type ChatFindOperations } from "./model";
function response(locations: Array<{ seq: number; role: "user" | "assistant" }>) {
function response(locations: Array<{ seq: number; role: "user" | "assistant"; count?: number }>) {
return {
agentId: "agent",
requestId: "request",
@@ -159,11 +159,60 @@ it("retains a failed search for retry and distinguishes failure from zero matche
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
expect(model.getSnapshot().error).toBe("Host disconnected");
expect(model.getSnapshot().failure).toBe("connection");
disconnected = false;
model.retry();
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ count: 0, error: null });
expect(model.getSnapshot()).toMatchObject({ count: 0, failure: null });
} finally {
model.close();
}
});
// `load` is a host RPC like `search`, so its failure is a connection failure and the
// widget must say so rather than blaming the reveal.
it("reports a failed timeline load as a connection failure", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {
throw new Error("Host disconnected");
},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", []);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
expect(model.getSnapshot().failure).toBe("connection");
} finally {
model.close();
}
});
it("reports a search result that outlived its epoch as a changed history", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch-2", [message(1)]);
expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" });
} finally {
model.close();
}
@@ -187,7 +236,77 @@ it("can load a search location before the initial chat history has arrived", asy
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null });
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, failure: null });
} finally {
model.close();
}
});
// The scope of the search is the whole chat, so the position spans every located
// message: the host's estimate until a message is on screen, the rendered count after.
it("reports the position across the whole chat and corrects it with rendered counts", async () => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant", count: 2 },
{ seq: 2, role: "assistant", count: 3 },
]);
},
async load() {},
async reveal(id, _query, occurrence) {
const count = id === "row-1" ? 2 : 1;
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 5 });
model.next();
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
expect(model.getSnapshot().count).toBe(5);
model.next();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
model.previous();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-1");
expect(model.getSnapshot()).toMatchObject({ occurrence: 1, count: 3 });
} finally {
model.close();
}
});
// COMPAT(timelineSearchCount): a host that reports no counts is worth one match per
// located message until a reveal replaces the estimate with the rendered count.
it("counts one match per message from a host that reports no counts", async () => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal(id, _query, occurrence) {
const count = id === "row-1" ? 2 : 1;
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 3 });
model.next();
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
model.next();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
} finally {
model.close();
}
@@ -1,26 +1,59 @@
import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client";
import type { StreamItem } from "@/types/stream";
type Location = AgentTimelineSearchPayload["locations"][number];
/**
* A message the host says contains the query. `count` is the host's estimate of its
* occurrences until a reveal replaces it with the rendered count, so the whole-chat
* position the widget shows is exact for every message that has been on screen. The
* wire field is optional; the search boundary fills it in, so the interior trusts it.
*/
type Location = Omit<AgentTimelineSearchPayload["locations"][number], "count"> & {
count: number;
};
interface Target {
id: string;
seq: number;
before: number;
}
/**
* Why a search stopped, so the widget can say something true. `connection` is the
* host call itself failing, `historyChanged` is the timeline moving under a result
* set, and `reveal` is a located match that never made it onto the screen.
*/
export type ChatFindFailure = "connection" | "historyChanged" | "reveal";
class ChatFindFailureError extends Error {
constructor(
readonly failure: ChatFindFailure,
options?: { cause: unknown },
) {
super(`Chat find failed: ${failure}`, options);
}
}
function tagged<T>(failure: ChatFindFailure, operation: Promise<T>): Promise<T> {
return operation.catch((cause: unknown) => {
throw cause instanceof ChatFindFailureError
? cause
: new ChatFindFailureError(failure, { cause });
});
}
interface Snapshot {
open: boolean;
query: string;
phase: "idle" | "searching" | "loading" | "ready" | "error";
selectedItemId: string | null;
/** Position across the whole chat: the search scope, not the selected message. */
occurrence: number;
count: number;
error: string | null;
failure: ChatFindFailure | null;
}
export interface ChatFindOperations {
search(query: string, cursor?: number): Promise<AgentTimelineSearchPayload>;
load(epoch: string, seq: number): Promise<unknown>;
reveal(
itemId: string,
messageId: string,
query: string,
occurrence: number,
signal: AbortSignal,
@@ -36,7 +69,7 @@ export class ChatFindModel {
selectedItemId: null,
occurrence: 0,
count: 0,
error: null,
failure: null,
};
private listeners = new Set<() => void>();
private historyListeners = new Set<() => void>();
@@ -46,6 +79,7 @@ export class ChatFindModel {
private locations: Location[] = [];
private resolved = new Map<Location, Target>();
private current = -1;
private inMessage = { occurrence: 0, count: 0 };
private abort = new AbortController();
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(private operations: ChatFindOperations) {}
@@ -73,7 +107,7 @@ export class ChatFindModel {
};
readonly close = () => {
this.cancel();
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null });
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, failure: null });
};
readonly setQuery = (query: string) => {
this.cancel();
@@ -81,13 +115,14 @@ export class ChatFindModel {
this.resultEpoch = null;
this.resolved.clear();
this.current = -1;
this.inMessage = { occurrence: 0, count: 0 };
this.publish({
query,
phase: query.trim() ? "searching" : "idle",
selectedItemId: null,
occurrence: 0,
count: 0,
error: null,
failure: null,
});
if (!query.trim()) return;
const signal = this.abort.signal;
@@ -102,20 +137,30 @@ export class ChatFindModel {
this.publish({
phase: "error",
selectedItemId: null,
error: error instanceof Error ? error.message : String(error),
// Every operation call site is tagged, so an untagged failure is a bug here
// rather than a known outcome; `connection` is the least misleading thing to
// say about one, and its copy already asks the reader to check and retry.
failure: error instanceof ChatFindFailureError ? error.failure : "connection",
});
}
private async search(signal: AbortSignal) {
try {
let cursor: number | undefined;
do {
const result = await this.operations.search(this.state.query, cursor);
const result = await tagged("connection", this.operations.search(this.state.query, cursor));
if (signal.aborted) return;
const historyChanged = this.epoch !== null && result.epoch !== this.epoch;
const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch;
if (historyChanged || searchChanged) throw new Error("History changed; search again");
if (historyChanged || searchChanged) throw new ChatFindFailureError("historyChanged");
this.resultEpoch = result.epoch;
this.locations.push(...result.locations);
this.locations.push(
...result.locations.map((location) => ({
seq: location.seq,
role: location.role,
// COMPAT(timelineSearchCount): hosts before v0.9.0 send no count; remove after 2027-09-22.
count: location.count ?? 1,
})),
);
cursor = result.nextCursor ?? undefined;
} while (cursor !== undefined);
await this.navigate(0, 1, signal);
@@ -130,7 +175,7 @@ export class ChatFindModel {
epoch !== this.resultEpoch &&
this.state.open
) {
this.fail(new Error("History changed; search again"), this.abort.signal);
this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal);
}
this.epoch = epoch;
this.items = items;
@@ -159,7 +204,7 @@ export class ChatFindModel {
this.historyListeners.delete(check);
signal.removeEventListener("abort", cancelled);
if (target) resolve(target);
else reject(new Error("Could not load this search location; retry"));
else reject(new ChatFindFailureError("reveal"));
};
const check = () => {
const target = this.target(location, false);
@@ -176,7 +221,7 @@ export class ChatFindModel {
private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) {
const epoch = this.resultEpoch;
if (epoch === null) return;
this.publish({ phase: "loading", error: null });
this.publish({ phase: "loading", failure: null });
let remaining = this.locations.length;
while (remaining-- > 0 && this.locations.length) {
index = (index + this.locations.length) % this.locations.length;
@@ -187,7 +232,7 @@ export class ChatFindModel {
this.items.some((item) => item.id === cached.id && item.timelineCursor?.seq === cached.seq);
let target = stillLoaded ? cached : this.target(location, true);
if (!target) {
await this.operations.load(epoch, location.seq);
await tagged("connection", this.operations.load(epoch, location.seq));
if (signal.aborted) return;
target = await this.waitForTarget(location, signal);
}
@@ -204,16 +249,14 @@ export class ChatFindModel {
);
index = this.locations.indexOf(location);
this.publish({ selectedItemId: target.id });
const result = await this.operations.reveal(
target.id,
this.state.query,
direction === 1 ? 0 : -1,
signal,
const result = await tagged(
"reveal",
this.operations.reveal(target.id, this.state.query, direction === 1 ? 0 : -1, signal),
);
if (signal.aborted) return;
if (result.count) {
this.current = index;
this.publish({ phase: "ready", ...result });
this.publish({ phase: "ready", ...this.position(location, result) });
return;
}
this.locations.splice(index, 1);
@@ -222,23 +265,32 @@ export class ChatFindModel {
this.operations.clear();
this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 });
}
private position(location: Location, revealed: { occurrence: number; count: number }) {
this.inMessage = revealed;
location.count = revealed.count;
let before = 0;
let total = 0;
for (const candidate of this.locations) {
if (candidate === location) before = total;
total += candidate.count;
}
return { occurrence: before + revealed.occurrence, count: total };
}
private async move(direction: 1 | -1) {
if (this.state.phase !== "ready" || !this.state.count) return;
this.cancel();
const signal = this.abort.signal;
try {
const occurrence = this.state.occurrence + direction;
if (occurrence >= 0 && occurrence < this.state.count && this.state.selectedItemId) {
const occurrence = this.inMessage.occurrence + direction;
if (occurrence >= 0 && occurrence < this.inMessage.count && this.state.selectedItemId) {
this.publish({ phase: "loading" });
const result = await this.operations.reveal(
this.state.selectedItemId,
this.state.query,
occurrence,
signal,
const result = await tagged(
"reveal",
this.operations.reveal(this.state.selectedItemId, this.state.query, occurrence, signal),
);
if (signal.aborted) return;
if (result.count) {
this.publish({ phase: "ready", ...result });
this.publish({ phase: "ready", ...this.position(this.locations[this.current]!, result) });
return;
}
}
@@ -1,5 +1,5 @@
import { afterEach, expect, it } from "vitest";
import { findRenderedMatches } from "./ranges.web";
import { findMessageMatches, findRenderedMatches } from "./ranges.web";
afterEach(() => {
document.body.replaceChildren();
@@ -10,6 +10,36 @@ function content(html: string) {
document.body.append(row);
return row;
}
function message(messageId: string, ...blocks: string[]) {
const root = document.createElement("div");
for (const block of blocks) {
const row = document.createElement("div");
row.dataset.messageId = messageId;
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${block}</div></div>`;
root.append(row);
}
document.body.append(root);
return root;
}
it("collects a message's occurrences across its block rows in row order", () => {
const root = message("message-1", "alpha one", "beta", "alpha two");
const other = document.createElement("div");
other.dataset.messageId = "message-2";
other.innerHTML =
'<div data-message-text="true"><div data-paseo-markdown-tag="p">alpha three</div></div>';
root.append(other);
expect(findMessageMatches(root, "message-1", "alpha").map((range) => range.toString())).toEqual([
"alpha",
"alpha",
]);
expect(
findMessageMatches(root, "message-1", "alpha").map(
(range) => range.startContainer.parentElement?.textContent,
),
).toEqual(["alpha one", "alpha two"]);
expect(findMessageMatches(root, "message-2", "alpha")).toHaveLength(1);
expect(findMessageMatches(null, "message-1", "alpha")).toEqual([]);
});
it("finds each actual occurrence across inline formatting with original Unicode offsets", () => {
const row = content(
'<div data-paseo-markdown-tag="p">İ😀hello <strong>world</strong> then <em>hello</em> world</div>',
@@ -4,6 +4,23 @@ const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"
const IGNORED =
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
/** The rows of one message, in reading order. Each is one Markdown block. */
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`),
);
}
/** Occurrences in one message, ordered by row and then by position inside the row. */
export function findMessageMatches(
root: HTMLElement | null,
messageId: string,
query: string,
): Range[] {
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
}
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
@@ -8,11 +8,11 @@ export interface ChatFindProps {
epoch: string | null;
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedItem(itemId: string): boolean;
visibleItemIds: ReadonlySet<string>;
revealLoadedMessage(messageId: string): boolean;
visibleMessageIds: ReadonlySet<string>;
children: ReactNode;
}
export interface ChatFindExpansionProps {
itemId: string;
messageId: string;
children(renderFullContent: boolean): ReactNode;
}
@@ -0,0 +1,100 @@
import { afterEach, expect, it } from "vitest";
import { createFindViewport } from "./viewport.web";
import type { ChatFindProps } from "./types";
import type { StreamViewportHandle } from "../strategy";
interface Capture {
occurrence?: { signal: AbortSignal; targetTop(): number | null };
}
function transcript(messageId: string, blocks: string[]): HTMLElement {
const root = document.createElement("div");
for (const [index, text] of blocks.entries()) {
const row = document.createElement("div");
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
row.setAttribute("data-message-id", messageId);
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${text}</div></div>`;
root.append(row);
}
document.body.append(root);
return root;
}
function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
};
const bindings: Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds"> =
{
viewportRef: { current: handle },
revealLoadedMessage: () => false,
visibleMessageIds: new Set(["message-1"]),
};
return createFindViewport({
getBindings: () => bindings,
getRoot: () => root,
highlightName: "paseo-chat-find-test",
});
}
afterEach(() => {
CSS.highlights.delete("paseo-chat-find-test");
document.body.replaceChildren();
});
it("counts occurrences across every block row of the message", async () => {
const root = transcript("message-1", ["needle one needle", "no hits", "needle three"]);
const capture: Capture = {};
const found = await viewport(root, capture).reveal(
"message-1",
"needle",
2,
new AbortController().signal,
);
expect(found).toEqual({ occurrence: 2, count: 3 });
});
// The settle loop asks for the target position every frame while the viewport moves,
// and rows mount and unmount as it does. A flat index into the message would name a
// different occurrence each time the row set changed; the chosen row owns it instead.
it("keeps the chosen occurrence when an earlier row unmounts mid-scroll", async () => {
const root = transcript("message-1", ["needle one needle", "needle three"]);
const capture: Capture = {};
const found = await viewport(root, capture).reveal(
"message-1",
"needle",
2,
new AbortController().signal,
);
expect(found).toEqual({ occurrence: 2, count: 3 });
const settled = capture.occurrence?.targetTop();
expect(settled).toBeTypeOf("number");
expect(highlightedText()).toEqual(["needle"]);
expect(highlightedRow()).toBe("message-1:block:1");
root.firstElementChild?.remove();
expect(capture.occurrence?.targetTop()).toBeTypeOf("number");
expect(highlightedRow()).toBe("message-1:block:1");
});
function highlightedRanges(): Range[] {
const highlight = CSS.highlights.get("paseo-chat-find-test");
return highlight ? Array.from(highlight).filter((range) => range instanceof Range) : [];
}
function highlightedText(): string[] {
return highlightedRanges().map((range) => range.toString());
}
function highlightedRow(): string | undefined {
const row =
highlightedRanges()[0]?.startContainer.parentElement?.closest<HTMLElement>(
"[data-history-row-id]",
);
return row?.dataset.historyRowId;
}
@@ -1,12 +1,17 @@
import type { ChatFindOperations } from "./model";
import type { ChatFindProps } from "./types";
import { findRenderedMatches } from "./ranges.web";
import { findMessageMatches, findMessageRows, findRenderedMatches } from "./ranges.web";
interface ViewportInput {
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedItem" | "visibleItemIds">;
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds">;
getRoot(): HTMLElement | null;
highlightName: string;
}
function rowOf(range: Range): HTMLElement | null {
return range.startContainer.parentElement?.closest<HTMLElement>("[data-history-row-id]") ?? null;
}
export function createFindViewport({
getBindings,
getRoot,
@@ -16,7 +21,9 @@ export function createFindViewport({
clear() {
CSS.highlights.delete(highlightName);
},
reveal(itemId, query, occurrence, signal) {
// A message renders as one row per Markdown block, so an occurrence can sit in
// any of them and the chosen one decides where the viewport lands.
reveal(messageId, query, occurrence, signal) {
return new Promise((resolve, reject) => {
let frame = 0;
let started = false;
@@ -40,23 +47,21 @@ export function createFindViewport({
return;
}
const current = getBindings();
if (!current.visibleItemIds.has(itemId)) {
current.revealLoadedItem(itemId);
if (!current.visibleMessageIds.has(messageId)) {
current.revealLoadedMessage(messageId);
frame = requestAnimationFrame(poll);
return;
}
if (!started) {
current.viewportRef.current?.scrollToMessage?.(itemId);
current.viewportRef.current?.scrollToMessage?.(messageId);
started = true;
}
const row = getRoot()?.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
);
if (!row || !row.getBoundingClientRect().height) {
const rows = findMessageRows(getRoot(), messageId);
if (!rows.some((row) => row.getBoundingClientRect().height)) {
frame = requestAnimationFrame(poll);
return;
}
const ranges = findRenderedMatches(row, query);
const ranges = findMessageMatches(getRoot(), messageId, query);
const index =
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
const range = ranges[index];
@@ -65,10 +70,18 @@ export function createFindViewport({
resolve({ occurrence: 0, count: 0 });
return;
}
current.viewportRef.current?.scrollToMessage?.(itemId, {
// The settle loop re-resolves the range every frame because scrolling
// invalidates it. Re-counting the whole message would let a row mounting or
// unmounting mid-scroll shift which occurrence a fixed index names, so the
// occurrence is pinned to its own row for the rest of this reveal.
const row = rowOf(range);
const indexInRow = ranges
.slice(0, index)
.filter((earlier) => rowOf(earlier) === row).length;
current.viewportRef.current?.scrollToMessage?.(messageId, {
signal,
targetTop(currentRow) {
const selected = findRenderedMatches(currentRow, query)[index];
targetTop() {
const selected = row ? findRenderedMatches(row, query)[indexInRow] : undefined;
if (!selected) return null;
CSS.highlights.set(highlightName, new Highlight(selected));
const widget = getRoot()?.querySelector<HTMLElement>(
@@ -153,7 +153,7 @@ describe("useChatOutline", () => {
timelineEpoch,
enabled,
viewportRef,
visibleItemIds: new Set(),
visibleMessageIds: new Set(),
onJumpError: vi.fn(),
}),
{
@@ -236,7 +236,7 @@ describe("useChatOutline", () => {
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
});
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const revealLoadedMessage = vi.fn(() => true);
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
@@ -253,7 +253,7 @@ describe("useChatOutline", () => {
},
];
const { result, rerender } = renderHook(
({ visibleItemIds }) =>
({ visibleMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -263,18 +263,18 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
visibleMessageIds,
revealLoadedMessage,
}),
{ initialProps: { visibleItemIds: new Set<string>() } },
{ initialProps: { visibleMessageIds: new Set<string>() } },
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
await act(async () => result.current.jumpToPrompt(1));
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ visibleItemIds: new Set(["older-prompt"]) });
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
});
@@ -286,7 +286,7 @@ describe("useChatOutline", () => {
const fetch = deferred<void>();
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const revealLoadedMessage = vi.fn(() => true);
const viewportRef = {
current: {
scrollToBottom: vi.fn(),
@@ -302,7 +302,7 @@ describe("useChatOutline", () => {
timelineCursor: { epoch: "epoch-1", seq: 1 },
};
const { result, rerender } = renderHook(
({ tail, visibleItemIds }) =>
({ tail, visibleMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -312,13 +312,13 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
visibleMessageIds,
revealLoadedMessage,
}),
{
initialProps: {
tail: [] as (typeof fetchedPrompt)[],
visibleItemIds: new Set<string>(),
visibleMessageIds: new Set<string>(),
},
},
);
@@ -327,11 +327,11 @@ describe("useChatOutline", () => {
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
await act(async () => fetch.resolve());
@@ -355,8 +355,8 @@ describe("useChatOutline", () => {
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-1", seq: 2 },
};
const revealLoadedItem = vi.fn();
revealLoadedItem.mockReturnValue(false);
const revealLoadedMessage = vi.fn();
revealLoadedMessage.mockReturnValue(false);
const { result } = renderHook(() =>
useChatOutline({
agentId: "agent-1",
@@ -367,8 +367,8 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef: { current: viewport },
onJumpError: vi.fn(),
visibleItemIds: new Set([livePrompt.id]),
revealLoadedItem,
visibleMessageIds: new Set([livePrompt.id]),
revealLoadedMessage,
}),
);
@@ -33,8 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
visibleItemIds?: ReadonlySet<string>;
revealLoadedItem?: (itemId: string) => boolean;
visibleMessageIds?: ReadonlySet<string>;
revealLoadedMessage?: (messageId: string) => boolean;
}
export interface ChatOutline {
@@ -53,8 +53,8 @@ export function useChatOutline({
enabled,
viewportRef,
onJumpError,
visibleItemIds,
revealLoadedItem,
visibleMessageIds,
revealLoadedMessage,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
@@ -136,8 +136,8 @@ export function useChatOutline({
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
if (target) {
if (pendingJump.hasScrolled) return;
if (visibleItemIds?.has(target.id) === false) {
revealLoadedItem?.(target.id);
if (visibleMessageIds?.has(target.id) === false) {
revealLoadedMessage?.(target.id);
return;
}
viewportRef.current?.scrollToMessage?.(target.id);
@@ -148,7 +148,7 @@ export function useChatOutline({
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
const jumpToPrompt = useCallback(
(seq: number) => {
@@ -156,7 +156,7 @@ export function useChatOutline({
setPendingJump(null);
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
if (loaded) {
if (revealLoadedItem?.(loaded.id)) {
if (revealLoadedMessage?.(loaded.id)) {
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
@@ -180,7 +180,7 @@ export function useChatOutline({
});
});
},
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
);
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
@@ -210,21 +210,113 @@ describe("stream presentation through installed plugins", () => {
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
it("leaves fetched native Markdown intact, including cross-paragraph references", () => {
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
// never leaves an empty row behind and the reference still resolves.
it("splits an assistant message into the same blocks through history and through the live head", () => {
const source = hydrateStreamState([
{
event: assistant("[Link][docs]\n\n[docs]: https://example.com"),
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
timestamp: new Date(1000),
},
]);
const messageId = source[0]!.id;
const present = (placement: "tail" | "head") =>
rows(
createStreamPresentation()({
...presentationOptions,
tail: placement === "tail" ? source : [],
head: placement === "head" ? source : [],
transform: undefined,
}),
);
expect(present("tail")).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "[Link][docs]\n\n[docs]: https://example.com",
},
{
id: `${messageId}:block:1`,
blockGroupId: messageId,
blockIndex: 1,
text: "Closing paragraph.",
},
]);
const blocks = (items: StreamItem[]) =>
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
});
// Rows are addressed by id from outside presentation, so the same text has to land on
// the same ids whether the reader watched it arrive or opened the chat afterwards.
it("gives a streamed message the block ids its hydrated text would have had", () => {
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
const harness = streamHarness();
for (let end = 8; end < text.length; end += 8) {
harness.send(assistant(text.slice(end - 8, end)));
}
harness.send(assistant(text.slice(text.length - (text.length % 8 || 8))));
const streamed = rows(harness.send({ type: "turn_completed", provider: "claude" }));
const hydrated = rows(
createStreamPresentation()({
...presentationOptions,
tail: hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]),
head: [],
transform: undefined,
}),
);
expect(streamed.map((item) => item.id)).toEqual(hydrated.map((item) => item.id));
expect(streamed.map((item) => item.kind === "assistant_message" && item.text)).toEqual(
hydrated.map((item) => item.kind === "assistant_message" && item.text),
);
});
it("repoints a block at the canonical source that replaced the streamed one", () => {
const harness = streamHarness();
harness.send(assistant("Streamed answer"));
const streamed = rows(harness.render());
expect(streamed[0]).not.toHaveProperty("timelineCursor");
const source = harness.source();
const live = source.head[0];
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
const present = createStreamPresentation();
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
const repointed = rows(
present({ ...presentationOptions, tail: [], head: [canonical], transform: undefined }),
);
expect(repointed).toMatchObject([
{
id: streamed[0]!.id,
text: "Streamed answer",
timelineCursor: { epoch: "epoch-1", seq: 42 },
},
]);
});
it("keeps a single-paragraph message a one-block group", () => {
const source = hydrateStreamState([
{ event: assistant("Only one paragraph."), timestamp: new Date(1000) },
]);
const messageId = source[0]!.id;
const result = createStreamPresentation()({
...presentationOptions,
tail: source,
head: [],
transform: undefined,
});
expect(result.tail).toEqual(source);
expect(result.tail[0]).toBe(source[0]);
expect(result.tail).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "Only one paragraph.",
},
]);
expect(result.tail[0]!.id).not.toBe(messageId);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
@@ -441,7 +533,13 @@ describe("timeline presentation", () => {
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
},
];
expect(projectTimelineItems(items)).toEqual(items);
expect(projectTimelineItems(items)).toEqual(
items.map((item) =>
item.kind === "assistant_message"
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
: item,
),
);
expect(projectTimelineItems(items)[0]).toBe(items[0]);
});
+40 -14
View File
@@ -23,6 +23,31 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
: next;
}
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
* addresses a message — find, scroll-to-message, history reveal — asks for this.
*/
export function getStreamItemMessageId(item: StreamItem): string {
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
}
/**
* A block is reusable only when it still stands for the same source state. Text and
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
* through the row — a reused block carrying a pre-canonical cursor reports a stale
* reading position and weakens fork-boundary resolution.
*/
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
return (
block.id === id &&
block.turnId === source.turnId &&
block.timestamp.getTime() === source.timestamp.getTime() &&
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
block.timelineCursor?.seq === source.timelineCursor?.seq
);
}
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -54,6 +79,13 @@ export function createStreamPresentation() {
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
/**
* One display row per Markdown block. Each block renders on its own, so a construct
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
* link reference definitions with the block that uses them, but a reference pointing
* at a definition several blocks away renders as literal text. Streamed messages
* always behaved this way; history now matches them.
*/
function nativeBlocks(item: StreamItem): StreamItem[] {
if (item.kind === "user_message") return [presentUserMessage(item)];
if (item.kind !== "assistant_message") return [item];
@@ -71,11 +103,9 @@ export function createStreamPresentation() {
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
const textBlocks = splitMarkdownBlocks(growingText);
if (prefix.length + textBlocks.length < 2) {
blocksBySource.set(item, [item]);
return [item];
}
const parsed = splitMarkdownBlocks(growingText);
// Whitespace-only text has no block, and a message still owns exactly one row.
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
const blocks = [...prefix];
for (const [offset, text] of textBlocks.entries()) {
@@ -87,9 +117,7 @@ export function createStreamPresentation() {
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
// Completed display blocks keep their first cursor and object identity while
// the source message grows. The source itself always retains the latest cursor.
if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) {
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
blocks.push(existing);
continue;
}
@@ -108,13 +136,11 @@ export function createStreamPresentation() {
return (input: PresentationInput) => {
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
// One rendering path: history splits the same way the live head does, and
// `blocksBySource` keeps both the live block identities and the split cost
// from being paid again when only the tail's array identity changed.
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
(item) => {
// Preserve live block identities at completion; fetched native Markdown
// stays whole so links and other cross-block constructs keep their context.
if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item];
return [item.kind === "user_message" ? presentUserMessage(item) : item];
},
nativeBlocks,
);
historySource = input.tail;
historyTransform = input.transform;
@@ -5,7 +5,7 @@ interface PromptJumpScrollMetrics {
}
export interface PromptJumpSettleViewport {
findTargetTop(itemId: string): number | null;
findTargetTop(messageId: string): number | null;
getContainerTop(): number;
getScrollMetrics(): PromptJumpScrollMetrics;
setScrollTop(scrollTop: number): void;
@@ -13,7 +13,7 @@ export interface PromptJumpSettleViewport {
}
export interface PromptJumpSettleController {
start(itemId: string): void;
start(messageId: string): void;
cancel(): void;
isActive(): boolean;
}
@@ -32,7 +32,7 @@ export function createPromptJumpSettleController(input: {
requestFrame: (callback: () => void) => number;
cancelFrame: (frameId: number) => void;
}): PromptJumpSettleController {
let activeItemId: string | null = null;
let activeMessageId: string | null = null;
let frameId: number | null = null;
let sampledFrames = 0;
let stableFrames = 0;
@@ -41,7 +41,7 @@ export function createPromptJumpSettleController(input: {
function finish(): void {
if (frameId !== null) input.cancelFrame(frameId);
unsubscribeFromUserIntent?.();
activeItemId = null;
activeMessageId = null;
frameId = null;
sampledFrames = 0;
stableFrames = 0;
@@ -54,11 +54,11 @@ export function createPromptJumpSettleController(input: {
function tick(): void {
frameId = null;
const itemId = activeItemId;
if (itemId === null) return;
const messageId = activeMessageId;
if (messageId === null) return;
sampledFrames += 1;
const targetTop = input.viewport.findTargetTop(itemId);
const targetTop = input.viewport.findTargetTop(messageId);
if (targetTop !== null) {
const delta = distanceFromLandingPosition(targetTop, input.viewport.getContainerTop());
if (Math.abs(delta) <= POSITION_TOLERANCE_PX) {
@@ -72,7 +72,7 @@ export function createPromptJumpSettleController(input: {
const before = input.viewport.getScrollMetrics();
input.viewport.setScrollTop(before.scrollTop + delta);
const after = input.viewport.getScrollMetrics();
const adjustedTargetTop = input.viewport.findTargetTop(itemId);
const adjustedTargetTop = input.viewport.findTargetTop(messageId);
let remainingDelta = 0;
if (adjustedTargetTop !== null) {
remainingDelta = distanceFromLandingPosition(
@@ -97,15 +97,15 @@ export function createPromptJumpSettleController(input: {
}
return {
start(itemId) {
start(messageId) {
finish();
activeItemId = itemId;
activeMessageId = messageId;
unsubscribeFromUserIntent = input.viewport.subscribeToUserIntent(finish);
scheduleNextFrame();
},
cancel: finish,
isActive() {
return activeItemId !== null;
return activeMessageId !== null;
},
};
}
+42 -3
View File
@@ -7,7 +7,12 @@ import React, {
useRef,
useState,
} from "react";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import {
defaultRangeExtractor,
measureElement as measureVirtualElement,
useVirtualizer,
type Range as VirtualRange,
} from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -36,6 +41,8 @@ import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
import { getStreamItemMessageId } from "./presentation";
import { useScrollToMessage } from "./use-scroll-to-message.web";
interface CreateWebStreamStrategyInput {
@@ -369,6 +376,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
// Chat find counts and highlights occurrences from the DOM, so every block row of
// the message it selected has to be mounted even when the scroll window is nowhere
// near it. Without this a hit in a far paragraph is invisible to the count and Next
// walks off to the following message.
const chatFindMessageId = useChatFindSelectedMessageId();
const chatFindRowIndexes = useMemo(() => {
if (!chatFindMessageId) return null;
const indexes = segments.historyVirtualized.flatMap((item, index) =>
getStreamItemMessageId(item) === chatFindMessageId ? [index] : [],
);
return indexes.length > 0 ? indexes : null;
}, [chatFindMessageId, segments.historyVirtualized]);
const rangeExtractor = useCallback(
(range: VirtualRange) => {
const visible = defaultRangeExtractor(range);
if (!chatFindRowIndexes) return visible;
return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right);
},
[chatFindRowIndexes],
);
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
@@ -379,6 +406,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return row ? estimateStreamItemHeight(row) : 120;
},
measureElement: measureVirtualElement,
rangeExtractor,
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
@@ -1192,7 +1220,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
<div
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</div>
));
@@ -1200,7 +1233,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const liveHeadRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
<div
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderLiveHeadRow(item, index, segments.liveHead)}
</div>
));
@@ -1253,6 +1291,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={virtualRow.key}
data-index={virtualRow.index}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
+11 -4
View File
@@ -40,13 +40,20 @@ export interface StreamEdgeSlotProps {
ListFooterComponentStyle?: StyleProp<ViewStyle>;
}
/**
* A caller that landed on one occurrence inside the message owns where the viewport
* settles. It resolved the occurrence against a specific row already, so it takes no
* row here and returns null once that row is gone.
*/
export interface ScrollToMessageOccurrence {
signal: AbortSignal;
targetTop(): number | null;
}
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (
itemId: string,
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
) => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
export interface StreamSegmentRenderers {
@@ -1,13 +1,16 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId } from "./presentation";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
import type { ScrollToMessageOccurrence } from "./strategy";
interface UseScrollToMessageInput {
active: boolean;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowVirtualizer: Virtualizer<HTMLElement, Element>;
historyVirtualized: readonly { id: string }[];
historyVirtualized: readonly StreamItem[];
cancelPendingStickToBottom: () => void;
setFollowOutput: (value: boolean) => boolean;
onNearBottomChange: (value: boolean) => void;
@@ -32,24 +35,22 @@ export function useScrollToMessage({
setFollowOutput,
onNearBottomChange,
}: UseScrollToMessageInput) {
const occurrenceRef = useRef<
{ signal: AbortSignal; targetTop(row: HTMLElement): number | null } | undefined
>(undefined);
const occurrenceRef = useRef<ScrollToMessageOccurrence | undefined>(undefined);
const removeAbortListener = useRef<(() => void) | null>(null);
const settleController = useMemo(
() =>
createPromptJumpSettleController({
viewport: {
findTargetTop(itemId) {
const container = scrollContainerRef.current;
const target = container?.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
);
findTargetTop(messageId) {
const occurrence = occurrenceRef.current;
if (occurrence?.signal.aborted) return null;
if (!target) return null;
if (occurrence) return occurrence.targetTop(target);
return target.getBoundingClientRect().top;
if (occurrence) return occurrence.targetTop();
// Every row of an assistant message carries the message id; the first one
// is the message's top.
const target = scrollContainerRef.current?.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
return target ? target.getBoundingClientRect().top : null;
},
getContainerTop() {
return scrollContainerRef.current?.getBoundingClientRect().top ?? 0;
@@ -102,10 +103,7 @@ export function useScrollToMessage({
}, [active, settleController]);
const scrollToMessage = useCallback(
(
itemId: string,
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
) => {
(messageId: string, occurrence?: ScrollToMessageOccurrence) => {
occurrenceRef.current = occurrence;
removeAbortListener.current?.();
const cancel = () => settleController.cancel();
@@ -118,7 +116,7 @@ export function useScrollToMessage({
setFollowOutput(false);
const mounted = container.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
`[data-message-id="${CSS.escape(messageId)}"]`,
);
if (mounted) {
const delta =
@@ -126,15 +124,17 @@ export function useScrollToMessage({
container.getBoundingClientRect().top -
PROMPT_JUMP_TOP_INSET_PX;
container.scrollTop += delta;
settleController.start(itemId);
settleController.start(messageId);
onNearBottomChange(false);
return;
}
const index = historyVirtualized.findIndex((item) => item.id === itemId);
const index = historyVirtualized.findIndex(
(item) => getStreamItemMessageId(item) === messageId,
);
if (index >= 0) {
rowVirtualizer.scrollToIndex(index, { align: "start" });
settleController.start(itemId);
settleController.start(messageId);
onNearBottomChange(false);
}
},
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getStreamItemMessageId } from "./presentation";
// Callers reveal a message, not a row: an assistant message is several block rows and
// the window has to open at the first of them.
function findMessageRowIndex(items: StreamItem[], messageId: string): number {
return items.findIndex((item) => getStreamItemMessageId(item) === messageId);
}
function findEarlierHistoryWindowStart(input: {
items: StreamItem[];
start: number;
itemId: string | undefined;
}): number {
const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1;
const targetIndex = input.itemId ? findMessageRowIndex(input.items, input.itemId) : -1;
const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start;
if (!input.itemId) {
return findMountedWindowStart({
@@ -90,7 +97,7 @@ export function useStreamHistoryWindow(input: {
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1;
const targetIndex = itemId ? findMessageRowIndex(items, itemId) : -1;
if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) {
return false;
}
+12 -8
View File
@@ -62,7 +62,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -605,10 +605,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const handleTimelineHistoryLoadError = useCallback(() => {
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
const visibleHistoryItemIds = useMemo(
// Chat find and the chat outline address messages, and an assistant message is a
// group of block rows, so this is a set of message ids and never of row ids.
const visibleMessageIds = useMemo(
() =>
new Set(
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id),
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
getStreamItemMessageId,
),
),
[baseRenderModel.history, baseRenderModel.segments.liveHead],
);
@@ -621,8 +625,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleItemIds: visibleHistoryItemIds,
revealLoadedItem: revealLoadedHistory,
visibleMessageIds,
revealLoadedMessage: revealLoadedHistory,
});
useImperativeHandle(
@@ -718,7 +722,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
>
<ChatFindExpansion itemId={item.id}>
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
{(renderFullContent) => (
<AssistantMessage
renderFullContent={renderFullContent}
@@ -1098,8 +1102,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedItem={revealLoadedHistory}
visibleItemIds={visibleHistoryItemIds}
revealLoadedMessage={revealLoadedHistory}
visibleMessageIds={visibleMessageIds}
>
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
@@ -87,6 +87,31 @@ function selectNodeContents(element: Element): Selection {
return selection;
}
interface TranscriptMessage {
messageId: string;
blocks: string[];
}
/**
* The web transcript's shape: a scroll container holding one row per Markdown block,
* each row carrying its message id and wrapping its own assistant message element.
*/
function mountTranscript(messages: TranscriptMessage[]): HTMLElement {
const transcript = document.createElement("div");
transcript.setAttribute("data-testid", "agent-chat-scroll");
for (const { messageId, blocks } of messages) {
for (const [index, text] of blocks.entries()) {
const row = document.createElement("div");
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
row.setAttribute("data-message-id", messageId);
row.innerHTML = `<div data-testid="assistant-message"><div data-paseo-markdown-tag="p">${text}</div></div>`;
transcript.append(row);
}
}
document.body.append(transcript);
return transcript;
}
function copiedMarkdown(selection: Selection): string | null {
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
}
@@ -110,6 +135,36 @@ describe("assistant selection copy ranges", () => {
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
});
it("copies a selection that runs across the block rows of one message", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
]);
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
});
it("does not replace the browser clipboard for a selection reaching a second message", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
{ messageId: "message-2", blocks: ["Reply paragraph."] },
]);
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
expect(copiedMarkdown(selectRange(blocks[1]!, 0, blocks[2]!, 5))).toBeNull();
});
// A retained inactive panel renders the same agent, so the same message id can exist
// twice in the document. A group is only ever read out of one transcript, and rows
// that belong to no transcript are not a group at all.
it("does not assemble a message group from rows outside a transcript", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
]);
document.body.append(...transcript.children);
transcript.remove();
const blocks = document.querySelectorAll('[data-paseo-markdown-tag="p"]');
expect(copiedMarkdown(selectRange(blocks[0]!, 0, blocks[1]!, 17))).toBeNull();
});
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
const firstMessage = mountFixture();
const secondMessage = mountFixture();
@@ -17,6 +17,9 @@ import {
} from "./markup";
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
const MESSAGE_ROW_SELECTOR = "[data-message-id]";
const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
@@ -64,20 +67,22 @@ export function createAssistantSelectionClipboardContent(
}
const range = selection.getRangeAt(0);
const startMessage = closestAssistantMessage(range.startContainer);
const endMessage = closestAssistantMessage(range.endContainer);
if (!startMessage || startMessage !== endMessage) {
const parts = selectedMessageParts(range);
if (!parts) {
return null;
}
const partialCode = createPartialCodeContent(range, startMessage);
if (partialCode) {
return partialCode;
if (parts.length === 1) {
const partialCode = createPartialCodeContent(range, parts[0]!.message);
if (partialCode) {
return partialCode;
}
}
const container = document.createElement("div");
const selected = cloneMarkdownSelection(range, startMessage);
container.append(selected);
for (const part of parts) {
container.append(cloneMarkdownSelection(part.range, part.message));
}
restoreMarkdownElements(container);
const markdown = turndown.turndown(container.innerHTML).trim();
@@ -407,6 +412,70 @@ function closestAssistantMessage(node: Node): Element | null {
return element?.closest(ASSISTANT_MESSAGE_SELECTOR) ?? null;
}
interface SelectedMessagePart {
message: Element;
range: Range;
}
function messageIdOf(node: Element): string | undefined {
return node.closest<HTMLElement>(MESSAGE_ROW_SELECTOR)?.dataset.messageId;
}
/**
* A retained inactive panel keeps rendering its chat, so the same agent — and the same
* message id — can exist twice in the document. The selection's own transcript is the
* only one that can answer for it.
*/
function assistantMessagesOfGroup(node: Element, messageId: string): Element[] {
const transcript = node.closest(CHAT_SCROLL_SELECTOR);
if (!transcript) {
return [];
}
const rows = transcript.querySelectorAll<HTMLElement>(messageRowSelector(messageId));
return Array.from(rows).flatMap((row) => {
const message = row.querySelector(ASSISTANT_MESSAGE_SELECTOR);
return message ? [message] : [];
});
}
/**
* The selection cut into one range per assistant message element it covers.
*
* An assistant message renders one row per Markdown block, so a selection that runs
* past a paragraph ends in a different element of the same message. Each block is
* serialized against its own element, exactly as a selection inside one block is, and
* the results are concatenated — Turndown never sees the row wrappers between them.
* A selection reaching a second message, or anything that is not assistant Markdown,
* copies as plain text.
*/
function selectedMessageParts(range: Range): SelectedMessagePart[] | null {
const start = closestAssistantMessage(range.startContainer);
const end = closestAssistantMessage(range.endContainer);
if (!start || !end) {
return null;
}
if (start === end) {
return [{ message: start, range }];
}
const messageId = messageIdOf(start);
if (!messageId || messageId !== messageIdOf(end)) {
return null;
}
const messages = assistantMessagesOfGroup(start, messageId);
const first = messages.indexOf(start);
const last = messages.indexOf(end);
if (first < 0 || last < first) {
return null;
}
return messages.slice(first, last + 1).map((message) => {
const part = message.ownerDocument.createRange();
part.selectNodeContents(message);
if (message === start) part.setStart(range.startContainer, range.startOffset);
if (message === end) part.setEnd(range.endContainer, range.endOffset);
return { message, range: part };
});
}
function restoreMarkdownElements(container: HTMLElement): void {
for (const ignored of container.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
ignored.remove();
@@ -54,6 +54,54 @@ describe("question form card core", () => {
});
});
test("keeps checked options and appends the other answer for multi-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
Fruits: "Apple, Cherry, durian",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
Fruits: "Apple, Cherry",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
Fruits: "durian",
});
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
});
test("replaces the selected option with the other answer for single-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
Provider: "OpenCode",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
Provider: "Codex",
});
});
test("shows text input for explicit other questions", () => {
const questions = parseQuestionFormQuestions({
questions: [
@@ -111,10 +111,13 @@ export function buildQuestionFormAnswers(
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
// Multi-select keeps the checked options and appends the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
@@ -123,8 +126,7 @@ export function buildQuestionFormAnswers(
}
}
if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
if (labels.length > 0) {
answers[q.header] = labels.join(", ");
}
}
@@ -0,0 +1,149 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import type { PendingPermission } from "@/types/shared";
import { QuestionFormCard } from "./question-form-card";
// Load translations so controls expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
* gap between that input and React state: the input owns its text and never replays state, so a
* card that drops the Other text from state alone keeps showing it while submit ignores it.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
function buildPermission(question: Record<string, unknown>): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
request: {
id: "perm-1",
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question] },
},
};
}
function mountCard(question: Record<string, unknown>) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
/>,
),
);
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
act(() => {
valueSetter.call(input, text);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
});
};
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
const submittedAnswers = (): Record<string, string> => {
const response = onRespond.mock.calls[0]?.[0];
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers };
}
const multiSelectQuestion = {
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
};
const singleSelectQuestion = {
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
};
describe("QuestionFormCard other answers", () => {
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.check("Apple");
card.check("Cherry");
card.type("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
});
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.type("durian");
card.check("Apple");
card.check("Banana");
expect(card.otherInput().value).toBe("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
});
it("replaces the selected option with the typed other answer (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.check("Codex");
card.type("OpenCode");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
});
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.type("OpenCode");
card.check("Codex");
expect(card.otherInput().value).toBe("");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
@@ -1,5 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -9,6 +9,7 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
@@ -258,6 +259,7 @@ function QuestionNav({
interface QuestionOtherInputProps {
qIndex: number;
inputRef: RefObject<EditingTextInputHandle | null>;
accessibilityLabel: string;
value: string;
placeholder: string;
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
inputRef,
accessibilityLabel,
value,
placeholder,
@@ -303,6 +306,7 @@ function QuestionOtherInput({
);
return (
<TextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
setSelections((prev) => ({ ...prev, [qIndex]: next }));
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
// Single-select: an option and a custom answer replace each other, as in Claude Code.
// Multi-select keeps both. The editing surface owns its text and never replays state
// (docs/forms.md), so clearing state alone would leave stale text on screen that
// submit ignores; clear the surface explicitly.
if (!multiSelect && otherTexts[qIndex]) {
setOtherTexts((prev) => {
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
otherInputRef.current?.replaceText("");
}
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, questions, selections],
[activeQuestionIndex, otherTexts, questions, selections],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
if (text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
}, []);
const setOtherText = useCallback(
(qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
if (!multiSelect && text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
},
[questions],
);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
@@ -554,6 +570,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
inputRef={otherInputRef}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder({
@@ -0,0 +1,16 @@
import { BackHandler, Platform } from "react-native";
import type { BackPressSource } from "./visibility-tracker";
/**
* Android's hardware Back press.
*
* iOS and web have no such press, and `react-native-web`'s `BackHandler` logs an error as soon as
* anything subscribes, so nothing is registered off Android.
*/
export const systemBackPress: BackPressSource = {
subscribe(onBackPress) {
if (Platform.OS !== "android") return () => {};
const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress);
return () => subscription.remove();
},
};
@@ -5,6 +5,7 @@ import {
import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
@@ -85,7 +86,11 @@ export function useIsolatedBottomSheetVisibility({
onCloseRef.current = onClose;
const tracker = useMemo(
() => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }),
() =>
createBottomSheetVisibilityTracker({
onClose: () => onCloseRef.current(),
backPress: systemBackPress,
}),
[],
);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
type BackPressSource,
type BottomSheetController,
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
@@ -20,21 +21,55 @@ class FakeBottomSheet implements BottomSheetController {
}
}
/**
* Stands in for Android's `BackHandler`, which offers the press to listeners newest-first and
* stops at the first one that claims it.
*/
class FakeBackPress implements BackPressSource {
private listeners: (() => boolean)[] = [];
subscribe(onBackPress: () => boolean): () => void {
this.listeners.push(onBackPress);
return () => {
const index = this.listeners.indexOf(onBackPress);
if (index >= 0) this.listeners.splice(index, 1);
};
}
/** True when a sheet claimed the press, false when it falls through to the navigator. */
press(): boolean {
for (let index = this.listeners.length - 1; index >= 0; index--) {
if (this.listeners[index]?.()) return true;
}
return false;
}
}
function setup() {
const sheet = new FakeBottomSheet();
const backPress = new FakeBackPress();
let closeCount = 0;
const tracker = createBottomSheetVisibilityTracker({
onClose: () => {
closeCount += 1;
},
backPress,
});
return {
sheet,
backPress,
tracker,
closeCount: () => closeCount,
};
}
/** Opens a sheet and runs it through the presentation the real sheet reports back. */
function openSheet(context: ReturnType<typeof setup>) {
context.tracker.attachController(context.sheet);
context.tracker.syncDesired({ visible: true });
context.tracker.handleSheetIndexChange(0);
}
describe("bottom sheet visibility tracker", () => {
it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => {
const { sheet, tracker } = setup();
@@ -160,4 +195,53 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
});
it("dismisses the sheet on a Back press instead of letting it reach the navigator", () => {
const context = setup();
openSheet(context);
expect(context.backPress.press()).toBe(true);
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
const context = setup();
context.tracker.attachController(context.sheet);
context.tracker.syncDesired({ visible: false });
expect(context.backPress.press()).toBe(false);
openSheet(context);
context.tracker.handleSheetIndexChange(-1);
context.tracker.handleSheetDismiss();
expect(context.backPress.press()).toBe(false);
});
it("gives the Back press to the sheet presented last", () => {
const backPress = new FakeBackPress();
const sheets = ["below", "above"].map((name) => {
const sheet = new FakeBottomSheet();
const tracker = createBottomSheetVisibilityTracker({ onClose: () => {}, backPress });
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(0);
return { name, sheet };
});
backPress.press();
expect(sheets.map((entry) => [entry.name, entry.sheet.events.at(-1)?.type])).toEqual([
["below", "present"],
["above", "dismiss"],
]);
});
it("stops claiming Back once the sheet leaves the tree", () => {
const context = setup();
openSheet(context);
context.tracker.attachController(null);
expect(context.backPress.press()).toBe(false);
});
});
@@ -8,6 +8,22 @@ export interface BottomSheetVisibilityInput {
isEnabled?: boolean;
}
/**
* The system Back press, as the tracker needs it.
*
* A sheet is presented through `@gorhom/portal`, which renders it into the app's own view tree
* rather than a native modal window, so Android delivers Back to the navigator underneath it
* unless something on top claims the press first.
*
* `subscribe` registers a listener and returns its removal. The listener returns `true` when it
* claims the press. Listeners are offered the press newest-first, so a tracker holds its
* subscription only while its sheet is on screen, and a stacked sheet answers before the one
* below it.
*/
export interface BackPressSource {
subscribe(onBackPress: () => boolean): () => void;
}
export interface BottomSheetVisibilityTracker {
attachController(controller: BottomSheetController | null): void;
syncDesired(input: BottomSheetVisibilityInput): void;
@@ -19,23 +35,46 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
export function createBottomSheetVisibilityTracker(opts: {
onClose: () => void;
backPress?: BackPressSource;
}): BottomSheetVisibilityTracker {
let controller: BottomSheetController | null = null;
let visible = false;
let isEnabled: boolean | undefined;
let phase: BottomSheetPhase = "closed";
let hasNotifiedClose = false;
let releaseBackPress: (() => void) | null = null;
function setPhase(next: BottomSheetPhase): void {
phase = next;
syncBackPress();
}
/** Holds the Back press for exactly the window the sheet is on screen, and no longer. */
function syncBackPress(): void {
const wantsBackPress = phase === "presented" && controller !== null;
if (wantsBackPress === (releaseBackPress !== null)) return;
if (!wantsBackPress) {
releaseBackPress?.();
releaseBackPress = null;
return;
}
releaseBackPress =
opts.backPress?.subscribe(() => {
dismiss();
return true;
}) ?? null;
}
function present(): void {
if (!controller || phase !== "closed") return;
phase = "presenting";
hasNotifiedClose = false;
setPhase("presenting");
controller.present();
}
function dismiss(): void {
if (!controller || phase === "closed" || phase === "dismissing") return;
phase = "dismissing";
setPhase("dismissing");
controller.dismiss();
}
@@ -48,6 +87,9 @@ export function createBottomSheetVisibilityTracker(opts: {
return {
attachController(next) {
controller = next;
// A sheet that left the tree cannot answer, so it lets go of the press without changing
// phase: the parent has not acknowledged the dismissal yet.
syncBackPress();
if (next && visible && isEnabled !== false) {
present();
}
@@ -61,7 +103,7 @@ export function createBottomSheetVisibilityTracker(opts: {
return;
}
if (phase === "dismissing") {
phase = "closed";
setPhase("closed");
hasNotifiedClose = false;
return;
}
@@ -70,21 +112,21 @@ export function createBottomSheetVisibilityTracker(opts: {
handleSheetIndexChange(index) {
if (index !== -1) {
if (phase === "presenting" || phase === "dismissing") {
phase = "presented";
setPhase("presented");
}
return;
}
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
setPhase("dismissing");
}
},
handleSheetDismiss() {
if (visible) {
phase = "dismissing";
setPhase("dismissing");
notifyClose();
return;
}
phase = "closed";
setPhase("closed");
hasNotifiedClose = false;
},
};
@@ -1788,7 +1788,7 @@ export const AgentControls = memo(function AgentControls({
onEditAgentProfiles={handleEditAgentProfiles}
onCreateAgentProfile={profileActions.create}
onEditAgentProfile={profileActions.edit}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption}
features={agent.features}
@@ -498,7 +498,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setViewedTimelineSync(serverId, sync);
sync.setActive(getIsAppVisible(appStateRef.current));
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) =>
sync.replaceOpenAgentIds(agentIds),
sync.replaceOpenTabAgentIds(agentIds),
);
return () => {
@@ -10,7 +10,7 @@ import { EditorView } from "@codemirror/view";
import { View, type View as ViewInstance } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { usePaneFocus } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
@@ -41,12 +41,7 @@ export function FileFind({
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
+280 -289
View File
@@ -27,6 +27,7 @@ import {
} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
@@ -75,7 +76,6 @@ export function useKeyboardShortcuts({
const openProjectPickerAction = useOpenAddProject();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const badgeModifierKeyRef = useRef<string | null | undefined>(undefined);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
@@ -107,271 +107,285 @@ export function useKeyboardShortcuts({
publishBrowserShortcutPolicy();
}, [isDesktopApp, publishBrowserShortcutPolicy]);
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
// to drive badge visibility, so we set the flag matching this runtime.
// Derived from the effective bindings: `null` when the user unassigned or
// rebound the jump shortcut, and no `event.key` ever equals null, so the
// badges simply never appear.
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
{ isMac, isDesktop: isDesktopApp },
bindings,
);
// The keyup listener matches the released key against the current modifier,
// so a modifier held while the jump binding changes could never be released
// and the badges would stay up until a blur. Clear them when it changes.
useEffect(() => {
resetModifiers();
}, [badgeModifierKey, resetModifiers]);
const setBadgeModifierDown = (down: boolean) => {
const state = useKeyboardShortcutsStore.getState();
if (isDesktopApp) {
state.setCmdOrCtrlDown(down);
} else {
state.setAltDown(down);
}
};
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
};
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"cycle-theme": cycleTheme,
};
const performShortcutAction = (
action: ShortcutAction,
event: KeyboardEvent | null,
browserFocusRestoreElement: HTMLElement | null = null,
): boolean => {
switch (action.kind) {
case "none":
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
keyboardWorkspaceSelectionRef.current = {
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
if (navigateToLastWorkspace()) {
return true;
}
router.replace(buildOpenProjectRoute());
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
case "router-back":
router.back();
return true;
case "router-push":
router.push(action.route as Parameters<typeof router.push>[0]);
return true;
case "open-project-picker":
void openProjectPickerAction();
return true;
case "callback":
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
if (event) {
captureCommandCenterFocusRestore(event);
} else {
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
return true;
}
};
const routeAndPerformShortcut = (input: {
action: string;
payload: KeyboardShortcutPayload;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}): boolean => {
const store = useKeyboardShortcutsStore.getState();
const shortcutAction = routeKeyboardShortcut(
{ action: input.action, payload: input.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(
shortcutAction,
input.domEvent,
input.browserFocusRestoreElement,
);
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
exitFocusMode();
}
return handled;
};
const resolveAndPerformShortcut = (input: {
event: KeyboardShortcutInput;
focusScope: KeyboardFocusScope;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}) => {
const store = useKeyboardShortcutsStore.getState();
const previousChordState = chordStateRef.current;
const result = resolveKeyboardShortcut({
event: input.event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope: input.focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
publishBrowserShortcutPolicy();
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (
shouldPublishBrowserShortcutPolicy({
isBrowserInput: "browserId" in input.event,
previousChordState,
nextChordState: result.nextChordState,
})
) {
publishBrowserShortcutPolicy(result.nextChordState);
}
if (result.preventDefault && input.domEvent) {
input.domEvent.preventDefault();
input.domEvent.stopPropagation();
}
if (!result.match) {
return;
}
const handled = routeAndPerformShortcut({
action: result.match.action,
payload: result.match.payload,
domEvent: input.domEvent,
browserFocusRestoreElement: input.browserFocusRestoreElement,
});
if (!handled || !input.domEvent) {
return;
}
if (result.match.preventDefault) {
input.domEvent.preventDefault();
}
if (result.match.stopPropagation) {
input.domEvent.stopPropagation();
}
};
// The window listeners must outlive ordinary re-renders: removing them resets
// any in-progress chord. Stable events let them read the latest render instead
// of being re-registered whenever a route, callback, or selection changes.
const handleKeyDown = useStableEvent((event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
if (dispatchTopWebOverlayKeyDown(event)) {
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (
key === "Escape" &&
pathname.startsWith("/settings") &&
!isMobile &&
hasActiveWebOverlay()
) {
return;
}
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
resolveAndPerformShortcut({
event,
focusScope,
domEvent: event,
});
});
const handleKeyUp = useStableEvent((event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === badgeModifierKey) {
setBadgeModifierDown(false);
}
});
const handleBrowserShortcutInput = useStableEvent((payload: unknown) => {
const input = parseBrowserShortcutInput(payload);
if (!input) {
return;
}
resolveAndPerformShortcut({
event: input,
focusScope: "browser",
domEvent: null,
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
});
});
useEffect(() => {
if (!enabled) return;
if (!shortcutsAvailable) return;
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
// to drive badge visibility, so we set the flag matching this runtime.
// Derived from the effective bindings: `null` when the user unassigned or
// rebound the jump shortcut, and no `event.key` ever equals null, so the
// badges simply never appear.
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
{ isMac, isDesktop: isDesktopApp },
bindings,
);
const setBadgeModifierDown = (down: boolean) => {
const state = useKeyboardShortcutsStore.getState();
if (isDesktopApp) {
state.setCmdOrCtrlDown(down);
} else {
state.setAltDown(down);
}
};
// The keyup listener matches the released key against the modifier derived
// when the effect last ran, so a modifier held while the jump binding
// changes can never be released -- the badges would stay up until a blur.
// Clear on change only: this effect also re-runs on every navigation, and
// clearing unconditionally would drop the badges mid Cmd+1, Cmd+2.
if (badgeModifierKeyRef.current !== badgeModifierKey) {
badgeModifierKeyRef.current = badgeModifierKey;
resetModifiers();
}
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
};
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
"toggle-agent-list": toggleAgentList,
"toggle-both-sidebars": toggleBothSidebars,
"cycle-theme": cycleTheme,
};
const performShortcutAction = (
action: ShortcutAction,
event: KeyboardEvent | null,
browserFocusRestoreElement: HTMLElement | null = null,
): boolean => {
switch (action.kind) {
case "none":
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
keyboardWorkspaceSelectionRef.current = {
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
if (navigateToLastWorkspace()) {
return true;
}
router.replace(buildOpenProjectRoute());
return true;
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
case "router-back":
router.back();
return true;
case "router-push":
router.push(action.route as Parameters<typeof router.push>[0]);
return true;
case "open-project-picker":
void openProjectPickerAction();
return true;
case "callback":
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
if (event) {
captureCommandCenterFocusRestore(event);
} else {
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
}
}
useKeyboardShortcutsStore
.getState()
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
return true;
}
case "shortcuts-dialog-toggle":
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
return true;
}
};
const routeAndPerformShortcut = (input: {
action: string;
payload: KeyboardShortcutPayload;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}): boolean => {
const store = useKeyboardShortcutsStore.getState();
const shortcutAction = routeKeyboardShortcut(
{ action: input.action, payload: input.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(
shortcutAction,
input.domEvent,
input.browserFocusRestoreElement,
);
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
exitFocusMode();
}
return handled;
};
const resolveAndPerformShortcut = (input: {
event: KeyboardShortcutInput;
focusScope: KeyboardFocusScope;
domEvent: KeyboardEvent | null;
browserFocusRestoreElement?: HTMLElement | null;
}) => {
const store = useKeyboardShortcutsStore.getState();
const previousChordState = chordStateRef.current;
const result = resolveKeyboardShortcut({
event: input.event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope: input.focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
publishBrowserShortcutPolicy();
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (
shouldPublishBrowserShortcutPolicy({
isBrowserInput: "browserId" in input.event,
previousChordState,
nextChordState: result.nextChordState,
})
) {
publishBrowserShortcutPolicy(result.nextChordState);
}
if (result.preventDefault && input.domEvent) {
input.domEvent.preventDefault();
input.domEvent.stopPropagation();
}
if (!result.match) {
return;
}
const handled = routeAndPerformShortcut({
action: result.match.action,
payload: result.match.payload,
domEvent: input.domEvent,
browserFocusRestoreElement: input.browserFocusRestoreElement,
});
if (!handled || !input.domEvent) {
return;
}
if (result.match.preventDefault) {
input.domEvent.preventDefault();
}
if (result.match.stopPropagation) {
input.domEvent.stopPropagation();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
if (dispatchTopWebOverlayKeyDown(event)) {
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (
key === "Escape" &&
pathname.startsWith("/settings") &&
!isMobile &&
hasActiveWebOverlay()
) {
return;
}
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
resolveAndPerformShortcut({
event,
focusScope,
domEvent: event,
});
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === badgeModifierKey) {
setBadgeModifierDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
@@ -382,18 +396,7 @@ export function useKeyboardShortcuts({
document.addEventListener("visibilitychange", handleBlurOrHide);
const browserShortcutSubscription = isElectronRuntime()
? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => {
const input = parseBrowserShortcutInput(payload);
if (!input) {
return;
}
resolveAndPerformShortcut({
event: input,
focusScope: "browser",
domEvent: null,
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
});
})
? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput)
: null;
return () => {
if (chordStateRef.current.timeoutId !== null) {
@@ -415,23 +418,11 @@ export function useKeyboardShortcuts({
}
};
}, [
bindings,
cycleTheme,
enabled,
exitFocusMode,
activeWorkspaceSelection,
isDesktopApp,
isMac,
isMobile,
isWorkspaceFocusModeEnabled,
keyboardActionDispatcher,
openProjectPickerAction,
pathname,
publishBrowserShortcutPolicy,
handleBrowserShortcutInput,
handleKeyDown,
handleKeyUp,
resetModifiers,
router,
shortcutsAvailable,
toggleAgentList,
toggleBothSidebars,
]);
}
+3 -2
View File
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
revealFailure: "تعذر عرض هذا التطابق. أعد المحاولة.",
searching: "جارٍ البحث…",
loading: "جارٍ التحميل…",
failed: "فشل",
retry: "إعادة المحاولة",
chatPosition: "{{current}} من {{total}} في الرسالة",
title: "بحث",
placeholder: "بحث في اللوحة",
+3 -2
View File
@@ -1,11 +1,12 @@
export const en = {
paneFind: {
searchFailed: "Could not search this chat. Check the host connection and retry.",
connectionFailure: "Could not search this chat. Check the host connection and retry.",
historyChangedFailure: "The chat changed while searching. Search again.",
revealFailure: "Could not show this match. Retry.",
searching: "Searching…",
loading: "Loading…",
failed: "Failed",
retry: "Retry",
chatPosition: "{{current}} of {{total}} in message",
title: "Find",
placeholder: "Find in pane",
close: "Close Find",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
historyChangedFailure: "El chat cambió durante la búsqueda. Vuelve a buscar.",
revealFailure: "No se pudo mostrar esta coincidencia. Vuelve a intentarlo.",
searching: "Buscando…",
loading: "Cargando…",
failed: "Error",
retry: "Reintentar",
chatPosition: "{{current}} de {{total}} en el mensaje",
title: "Buscar",
placeholder: "Buscar en el panel",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
historyChangedFailure: "La conversation a changé pendant la recherche. Relancez la recherche.",
revealFailure: "Impossible d’afficher ce résultat. Réessayez.",
searching: "Recherche…",
loading: "Chargement…",
failed: "Échec",
retry: "Réessayer",
chatPosition: "{{current}} sur {{total}} dans le message",
title: "Rechercher",
placeholder: "Rechercher dans le panneau",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
historyChangedFailure: "検索中にチャットが変更されました。もう一度検索してください。",
revealFailure: "この一致を表示できませんでした。再試行してください。",
searching: "検索中…",
loading: "読み込み中…",
failed: "失敗",
retry: "再試行",
chatPosition: "メッセージ内 {{current}} / {{total}}",
title: "検索",
placeholder: "ペイン内を検索",
+3 -2
View File
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
revealFailure: "이 일치 항목을 표시할 수 없습니다. 다시 시도하세요.",
searching: "검색 중…",
loading: "불러오는 중…",
failed: "실패",
retry: "다시 시도",
chatPosition: "메시지 내 {{current}} / {{total}}",
title: "찾기",
placeholder: "패널에서 찾기",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
historyChangedFailure: "A conversa mudou durante a busca. Busque novamente.",
revealFailure: "Não foi possível mostrar esta correspondência. Tente novamente.",
searching: "Buscando…",
loading: "Carregando…",
failed: "Falhou",
retry: "Tentar novamente",
chatPosition: "{{current}} de {{total}} na mensagem",
title: "Buscar",
placeholder: "Buscar no painel",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
historyChangedFailure: "Чат изменился во время поиска. Выполните поиск снова.",
revealFailure: "Не удалось показать это совпадение. Повторите попытку.",
searching: "Поиск…",
loading: "Загрузка…",
failed: "Ошибка",
retry: "Повторить",
chatPosition: "{{current}} из {{total}} в сообщении",
title: "Найти",
placeholder: "Найти в панели",
+3 -2
View File
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
searchFailed: "无法搜索此聊天。请检查主机连接后重试。",
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
revealFailure: "无法显示此匹配项。请重试。",
searching: "搜索中…",
loading: "加载中…",
failed: "失败",
retry: "重试",
chatPosition: "消息内第 {{current}} 项,共 {{total}} 项",
title: "查找",
placeholder: "在窗格中查找",
@@ -550,6 +550,36 @@ describe("keyboard-shortcuts", () => {
expectNoShortcutResolution({ event, context });
});
// A rebound pane-focus shortcut has to fire wherever the user is typing.
// Its default combo carries `editable: false` so that Cmd+Shift+Arrow keeps
// selecting text (the two cases above), and that guard describes the default
// combo rather than the action, so it must not survive the rebind.
describe("a rebound pane-focus shortcut", () => {
const PANE_FOCUS_DOWN_BINDING = "workspace-pane-focus-down-cmd-shift-down";
// macOS emits U+2206 for Option+J; the stored combo comes from the code.
const altJ = { key: "\u2206", code: "KeyJ", altKey: true };
it.each(["message-input", "editable"] as const)("fires with %s focused", (focusScope) => {
const result = resolveShortcut({
event: altJ,
context: { isMac: true, focusScope },
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
});
expect(result.match?.action).toBe("workspace.pane.focus.down");
});
it("still fires outside a text field", () => {
const result = resolveShortcut({
event: altJ,
context: { isMac: true, focusScope: "other" },
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
});
expect(result.match?.action).toBe("workspace.pane.focus.down");
});
});
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
@@ -593,6 +623,42 @@ describe("keyboard-shortcuts", () => {
});
});
it("completes a chord whose second step carries a modifier", () => {
const bindings = buildEffectiveBindings({
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K Ctrl+J",
});
// The browser emits a keydown for the bare Control key before every combo,
// so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two.
const firstModifier = resolveShortcut({
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
context: { isMac: false, isDesktop: true },
bindings,
});
const firstStep = resolveShortcut({
event: { key: "k", code: "KeyK", ctrlKey: true },
context: { isMac: false, isDesktop: true },
chordState: firstModifier.nextChordState,
bindings,
});
expect(firstStep.nextChordState.step).toBe(1);
const secondModifier = resolveShortcut({
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
context: { isMac: false, isDesktop: true },
chordState: firstStep.nextChordState,
bindings,
});
const secondStep = resolveShortcut({
event: { key: "j", code: "KeyJ", ctrlKey: true },
context: { isMac: false, isDesktop: true },
chordState: secondModifier.nextChordState,
bindings,
});
expect(secondStep.match?.action).toBe("command-center.toggle");
});
it("schedules a chord reset timeout for advancing candidates", () => {
vi.useFakeTimers();
@@ -7,6 +7,7 @@ import type {
} from "@/keyboard/actions";
import {
chordStringToShortcutKeys,
isModifierKeyCode,
type KeyCombo,
parseChordString,
} from "@/keyboard/shortcut-string";
@@ -1229,14 +1230,33 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
}
const when = withoutDefaultComboGuard(binding.when);
if (!binding.help?.defaultDisplayKeys) {
return { ...binding, combo: override, parsedChord };
return { ...binding, combo: override, parsedChord, when };
}
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
return { ...binding, combo: override, parsedChord, help };
return { ...binding, combo: override, parsedChord, when, help };
});
}
/**
* `editable: false` is a statement about a binding's *default* combo, not
* about its action: the pane-focus defaults carry it so that Cmd+Shift+Arrow
* keeps selecting text in a field instead of moving pane focus. An override
* replaces that combo, so the guard no longer describes anything and has to
* go, the same way `defaultDisplayKeys` does — otherwise the combo the user
* picked in Settings silently refuses to fire wherever they are typing.
*
* The other guards stay. Platform, command center, terminal and focus scope
* are properties of the action and of where it makes sense, and none of them
* change because the keys did.
*/
function withoutDefaultComboGuard(when: ShortcutWhen | undefined): ShortcutWhen | undefined {
if (when?.editable !== false) return when;
const { editable: _editable, ...rest } = when;
return rest;
}
// --- Matching engine ---
function parseDigit(event: KeyboardShortcutInput): number | null {
@@ -1517,6 +1537,13 @@ export function resolveKeyboardShortcut(input: {
preventDefault: boolean;
} {
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
// Pressing a modifier emits its own keydown before the combo that holds it,
// so a chord waiting on `Ctrl+J` sees a bare `Control` first. That keydown
// matches no combo, and resolving it would drop the chord back to its first
// step. It decides nothing: leave the chord where it is.
if (isModifierKeyCode(event.code)) {
return { match: null, nextChordState: chordState, preventDefault: false };
}
if (chordState.step === 0) {
return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings });
}
+10 -1
View File
@@ -245,8 +245,17 @@ export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
return parts.length > 0 ? parts.join("+") : null;
}
/**
* Whether a keydown carries a modifier key itself rather than a key pressed
* with one. The browser emits one of these before every combo that holds a
* modifier, and it is the prefix of a combo, never a combo of its own.
*/
export function isModifierKeyCode(code: string): boolean {
return MODIFIER_CODES.has(code);
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
if (isModifierKeyCode(event.code)) {
return null;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { isFindShortcut } from "./find-shortcut";
const MAC = { isMac: true };
const NON_MAC = { isMac: false };
function keydown(
overrides: Partial<Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">>,
) {
return {
key: "f",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
...overrides,
};
}
describe("on macOS", () => {
it("opens Find with Command+F", () => {
expect(isFindShortcut(keydown({ metaKey: true }), MAC)).toBe(true);
});
it("leaves Control+F to text editing", () => {
expect(isFindShortcut(keydown({ ctrlKey: true }), MAC)).toBe(false);
});
it("ignores Control+Command+F", () => {
expect(isFindShortcut(keydown({ metaKey: true, ctrlKey: true }), MAC)).toBe(false);
});
it("ignores Shift and Alt variants", () => {
expect(isFindShortcut(keydown({ metaKey: true, shiftKey: true }), MAC)).toBe(false);
expect(isFindShortcut(keydown({ metaKey: true, altKey: true }), MAC)).toBe(false);
});
});
describe("off macOS", () => {
it("opens Find with Control+F", () => {
expect(isFindShortcut(keydown({ ctrlKey: true }), NON_MAC)).toBe(true);
});
it("ignores Meta+F", () => {
expect(isFindShortcut(keydown({ metaKey: true }), NON_MAC)).toBe(false);
});
it("ignores Control+Meta+F", () => {
expect(isFindShortcut(keydown({ ctrlKey: true, metaKey: true }), NON_MAC)).toBe(false);
});
it("ignores Shift and Alt variants", () => {
expect(isFindShortcut(keydown({ ctrlKey: true, shiftKey: true }), NON_MAC)).toBe(false);
expect(isFindShortcut(keydown({ ctrlKey: true, altKey: true }), NON_MAC)).toBe(false);
});
});
it("matches an uppercase F from a shifted keyboard layout", () => {
expect(isFindShortcut(keydown({ key: "F", metaKey: true }), MAC)).toBe(true);
expect(isFindShortcut(keydown({ key: "F", ctrlKey: true }), NON_MAC)).toBe(true);
});
it("ignores other keys", () => {
expect(isFindShortcut(keydown({ key: "g", metaKey: true }), MAC)).toBe(false);
expect(isFindShortcut(keydown({ key: "g", ctrlKey: true }), NON_MAC)).toBe(false);
});
@@ -0,0 +1,24 @@
export interface FindShortcutPlatform {
isMac: boolean;
}
/**
* The one Find keystroke policy: Command+F on macOS, Control+F everywhere else.
*
* Control+F is a macOS text-editing binding that moves the caret forward, so a
* surface that also claims it there eats a keystroke the text editor owns.
*
* The platform is a parameter rather than a lookup because this module is
* imported by the terminal emulator runtime, which is bundled on its own into
* the terminal WebView and cannot load React Native. App surfaces pass
* `findShortcutPlatform()` from `@/pane-find`.
*/
export function isFindShortcut(
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
platform: FindShortcutPlatform,
): boolean {
const primaryModifier = platform.isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
}
+17 -6
View File
@@ -33,6 +33,15 @@ import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut";
export { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut";
/** The platform every Find surface judges the shortcut against. */
export function findShortcutPlatform(): FindShortcutPlatform {
return { isMac: getShortcutOs() === "mac" };
}
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
@@ -166,12 +175,14 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
keyCode?: number;
};
if (isImeComposingKeyboardEvent(key)) return;
if (
(key.metaKey || key.ctrlKey) &&
!key.altKey &&
!key.shiftKey &&
key.key.toLowerCase() === "f"
) {
const shortcut = {
key: key.key,
metaKey: key.metaKey === true,
ctrlKey: key.ctrlKey === true,
shiftKey: key.shiftKey === true,
altKey: key.altKey === true,
};
if (isFindShortcut(shortcut, findShortcutPlatform())) {
// RN Web inputs stop keydown before the pane's document listener.
event.preventDefault();
event.stopPropagation();
@@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => {
).toEqual(directoryPlacement);
store.clearSession(serverId);
});
it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => {
const serverId = "agent-replica-timeline-membership";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
// A conversation opened from history arrives over its timeline, without the
// project placement the directory carries, and is a member from then on.
replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only"));
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
replica.commitChanges([], [], []);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
});
@@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { AgentStoreProjection } from "./internal/agent-store";
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
return agent.projectPlacement
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
: null;
// A member hydrated from its timeline or restored from the replica cache carries
// no placement. Every other path in this flow derives one from the working
// directory, so this one does too; returning nothing here would drop the member
// from the merged directory and delete the conversation on the next catch-up.
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry {
return {
agent: projectAgentSnapshot(agent),
project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }),
};
}
export interface AgentLifecycleToken {
@@ -164,8 +170,7 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const merged = new Map<string, FetchAgentsEntry>();
for (const agent of previous.values()) {
const entry = projectAgentDirectoryEntry(agent);
if (entry) merged.set(agent.id, entry);
merged.set(agent.id, projectAgentDirectoryEntry(agent));
}
for (const entry of entries) merged.set(entry.agent.id, entry);
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
@@ -6,7 +6,10 @@ import {
type SqliteValue,
} from "@/runtime/replica-cache/row-store-sqlite";
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
DaemonClient,
WorkspaceLabelListPayload,
} from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeProjectDescriptor,
@@ -15,6 +18,7 @@ import {
} from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
import { useWorkspaceLabels } from "@/workspace-labels";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import {
DirectoryRefreshSupersededError,
@@ -29,6 +33,8 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
class FakeDirectoryClient {
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0;
@@ -144,19 +150,36 @@ class FakeDirectoryClient {
});
}
getLastServerInfoMessage(): null {
return null;
observeWorkspaceLabels() {
return subscriptionFixture(this.listWorkspaceLabels(), () => () => {});
}
async listWorkspaceLabels(): Promise<WorkspaceLabelListPayload> {
this.listWorkspaceLabelsCalls += 1;
return {
requestId: "workspace-labels",
labels: [{ name: "Urgent", color: "red" }],
sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 },
};
}
getLastServerInfoMessage() {
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
}
}
const serverIds = new Set<string>();
function createDirectory(serverId: string): {
function createDirectory(
serverId: string,
options?: { workspaceLabels?: boolean },
): {
client: FakeDirectoryClient;
directory: DirectorySync;
} {
serverIds.add(serverId);
const client = new FakeDirectoryClient();
client.supportsWorkspaceLabels = options?.workspaceLabels === true;
const directory = new DirectorySync(serverId, {
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
@@ -207,6 +230,7 @@ function createAgent(serverId: string, id: string) {
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
useWorkspaceLabels.setState({ hosts: {} });
});
describe("DirectorySync session readiness", () => {
@@ -640,6 +664,63 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("reconciles agent changes on top of the accepted cached baseline", async () => {
const serverId = "cached-agent-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "cached-agent");
const releaseNetwork = client.holdAgentFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map([[cachedAgent.id, cachedAgent]]),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshAgents();
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
releaseNetwork({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastAgentOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
@@ -1292,3 +1373,103 @@ it("fills every cached workspace beneath live updates received during the SQLite
await cache.flush();
database.close();
});
function createWorkspaceLabelDirectory(serverId: string) {
const { client, directory } = createDirectory(serverId, { workspaceLabels: true });
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: {
directorySync: true,
projectList: true,
workspaceLabels: true,
workspaceMultiplicity: true,
},
});
return { client, directory };
}
async function flushWorkspaceLabels(): Promise<void> {
for (let index = 0; index < 4; index += 1) await Promise.resolve();
}
describe("DirectorySync workspace labels", () => {
it("loads the catalog when an agent route is open before the sidebar", async () => {
const serverId = "workspace-labels-route-then-full";
const { directory } = createWorkspaceLabelDirectory(serverId);
directory.setAgentRouteDemand(["agent-1"]);
await directory.refreshDemand();
directory.setDemand({}, true);
await flushWorkspaceLabels();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
it("loads the catalog when the sidebar demands the directory first", async () => {
const serverId = "workspace-labels-full-first";
const { directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
it("keeps the picker online during a full refresh", async () => {
const serverId = "workspace-labels-refresh-no-flicker";
const { client, directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
const callsBeforeRefresh = client.listWorkspaceLabelsCalls;
const statuses: string[] = [];
const unsubscribe = useWorkspaceLabels.subscribe((state) => {
const status = state.hosts[serverId]?.status;
if (status) statuses.push(status);
});
await directory.refreshDemand();
expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh);
expect(statuses).not.toContain("offline");
unsubscribe();
directory.dispose();
});
it("restores the catalog after reconnecting", async () => {
const serverId = "workspace-labels-reconnect";
const { client, directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
directory.connectionChanged({
client: null,
status: "offline",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline");
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
await flushWorkspaceLabels();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
});
@@ -189,6 +189,8 @@ export class DirectorySync {
this.connection = connection;
this.abortPendingSessionWaits();
if (!connection.client || connection.status !== "online") return true;
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
void this.connectWorkspaceLabels().catch(() => undefined);
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
return true;
}
@@ -32,8 +32,11 @@ class MemoryStorage implements ReplicaRowStore {
writes = 0;
cleanups = 0;
nextWriteFailure: Error | null = null;
persistentWriteFailure: Error | null = null;
readGate: Promise<void> | null = null;
onRead: (() => void) | null = null;
/** Throws once a read goes past this count, so a non-terminating read loop fails the test. */
readLimit: number | null = null;
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
return `${row.serverId}:${row.kind}:${row.id}`;
@@ -47,6 +50,11 @@ class MemoryStorage implements ReplicaRowStore {
ids?: readonly string[],
): Promise<ReplicaRow[]> {
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
if (this.readLimit !== null && this.reads.length > this.readLimit) {
throw new Error(
`replica cache read ${this.reads.length} passed the limit of ${this.readLimit}`,
);
}
this.onRead?.();
await this.readGate;
const acceptedKinds = new Set(kinds);
@@ -71,6 +79,7 @@ class MemoryStorage implements ReplicaRowStore {
async apply(changes: ReplicaRowChanges): Promise<void> {
this.writes += 1;
if (this.persistentWriteFailure) throw this.persistentWriteFailure;
if (this.nextWriteFailure) {
const error = this.nextWriteFailure;
this.nextWriteFailure = null;
@@ -432,6 +441,34 @@ describe("ReplicaCache", () => {
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
});
it("gives up a read instead of retrying while the store keeps rejecting writes", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
storage.persistentWriteFailure = new Error("QuotaExceededError");
storage.reads.length = 0;
storage.readLimit = 5;
deleteDirectory(cache, SERVER_ID);
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
expect(storage.reads.length).toBeLessThanOrEqual(1);
});
it("still reads a host whose rows are stored when another host's write is rejected", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage, noLegacyCleanup);
cache.setHosts([SERVER_ID, "other-host"]);
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
storage.persistentWriteFailure = new Error("QuotaExceededError");
commitDirectory(cache, "other-host", directory());
expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.id).toBe("workspace-1");
});
it("discards a durable read when the host changes while it is in flight", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
@@ -983,7 +983,11 @@ export class ReplicaCache {
try {
await this.prepareStore();
while (this.activeServerIds.has(serverId)) {
await this.flush();
// A read may only answer from rows the store already holds, so it waits for this host's
// accepted commits to land. A store that rejects them will keep rejecting them: fail closed
// rather than re-attempt the write on every pass. A write rejected for another host leaves
// this host's stored rows readable.
if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return [];
const revision = this.hostRevisions.get(serverId) ?? 0;
const rows = await this.rowStore.read(serverId, kinds, ids);
if (this.canReadHostRevision(serverId, revision)) return rows;
@@ -1173,20 +1177,26 @@ export class ReplicaCache {
}
async flush(): Promise<void> {
await this.persist();
await this.syncPending();
}
/** Resolves to whether every pending change reached the store. */
private async syncPending(): Promise<boolean> {
const persisted = await this.persist();
await this.writeQueue.catch(() => undefined);
return persisted;
}
private async flushPending(): Promise<void> {
await this.persist();
}
private async persist(): Promise<void> {
private async persist(): Promise<boolean> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
if (!this.hasPendingChanges()) return;
if (!this.hasPendingChanges()) return true;
const write = this.writeQueue
.catch(() => undefined)
.then(async () => {
@@ -1206,11 +1216,16 @@ export class ReplicaCache {
} catch {
this.restorePendingChanges(pending);
if (this.hasPendingChanges()) this.schedulePersist();
return false;
}
return undefined;
return true;
});
this.writeQueue = write;
await write;
// The queue only sequences writes; every consumer decides for itself what a failure means.
this.writeQueue = write.then(
() => undefined,
() => undefined,
);
return write;
}
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
@@ -377,8 +377,7 @@ export function KeyboardShortcutsSection() {
event.preventDefault();
event.stopPropagation();
const key = event.key ?? "";
if (key === "Backspace") {
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
return;
}

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