Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 5bc78b951b fix(diff): align untracked patches and comparison modes
Read untracked bodies with --no-textconv so they follow the same patch
rules as tracked bodies. Include the comparison-side mode in worktree
identity so a base file type change cannot keep stale hunks. Use object
parameters for the identity inputs.
2026-11-13 11:12:24 +01:00
Mohamed Boudra e2f31e5db2 fix(diff): identify worktree files by content
Stat-only identity changes re-sent unchanged diff bodies after a touch or
git add. Worktree tracked and untracked files now share one content identity
built from the comparison-side blob, working-tree content, and Git
interpretation. Stat metadata no longer forces body delivery, while real
content changes still change the identity.

Closes #9563
2026-11-13 11:00:23 +01:00
Mohamed BoudraandJacek Tomaszewski af6ca46ae3 fix(plugin)!: drop the always-empty timeline from agent.turn.after (#9573)
agent.turn.after no longer declares timeline; plugins read the turn with
timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true }) filtered by event.turnId.
Plugins built against the old SDK keep receiving timeline: [] behind COMPAT(pluginTurnTimeline).

Closes #9315

Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-11-13 15:22:53 +07:00
Mohamed Boudra 8dc979ec2f Shift+Tab in the iPad composer no longer inserts a tab (#9561)
* Shift+Tab in the iPad composer no longer inserts a tab

On iPad with a hardware keyboard, Shift+Tab in the composer cycled the
agent mode and the composer also inserted a tab. The native window
reports presses to JavaScript after the text control has handled them,
so the shortcut pipeline cannot prevent the default.

The hardware keyboard source now claims, up front, the Shift and Option
shortcuts whose binding is active in the current focus scope, and
recomputes the claims when the focus surface or command center changes.
iOS registers them as key commands so the text control never sees them.
The window still emits the press, so the shortcut runs once.

Closes #9468

* Claim every step of a rebound chord shortcut

A binding rebound to a chord such as Mod+J Shift+Tab only claimed its
first step, so finishing the chord in the iPad composer still typed a tab.

* Claim only the first step of a chord shortcut

Claiming every step of a chord held keys like Shift+A for as long as the
chord's binding was active, so the composer could no longer type a
capital A. A later step is a shortcut only while its chord is in progress.

* Expect no Option claims now that iPad tab switching uses Cmd+Shift

Main moved tab switching on Apple platforms with desktop bindings to
Cmd+Shift+[ / ], which text controls do not type, so the default claims
are only Shift+Tab while the composer is focused.
2026-11-13 14:12:01 +07:00
Papa Goose 70aeb7b45b fix(pi,omp): treat null optional tool arguments as omitted (#9506)
Models commonly send null for optional tool arguments, such as read offset/limit
(captured in #7011) and bash timeout. Pi and OMP treat null as omitted, but the
Paseo mappers rejected it, so these calls fell back to unknown tool cards that
show raw JSON input and output instead of Read/Shell cards.
2026-11-13 07:00:29 +00:00
Mohamed Boudra 8b3b2a9ea3 Stop re-sending unchanged Changes snapshots to apps older than 0.16 (#9562)
* fix(server): stop re-sending unchanged Changes snapshots to pre-0.16 apps

The shared diff engine commits a revision for every observed input change:
ignored-file writes, same-content saves, mtime touches, index rewrites, and
ref moves that only change the resolved SHAs. Binary-stream clients absorb
those cheaply by reusing unchanged file objects. The legacy adapter turned
each revision into a full JSON snapshot, so old apps were re-sent the whole
diff (5 MB+ on large checkouts) whenever the repository was touched.

The adapter now sends a snapshot only when it differs from the last one it
sent to that subscriber, as 0.15 did. The check lives here, not in the
engine, because the JSON snapshot drops the resolved SHAs: a commit that
moves HEAD in All mode is a real revision for new clients and a no-op for
old ones.

* test(server): wait for the revision end before asserting no legacy push
2026-11-13 13:09:38 +07:00
b6952995be feat(app): use Cmd+Shift+[ / ] for previous/next tab on macOS desktop (#7692)
* feat(app): use Cmd+Shift+[ / ] for previous/next tab on macOS desktop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): drop Alt+Shift tab navigation on macOS desktop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): cover previous/next tab shortcuts on web and macOS desktop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): adopt desktopBindings condition for tab navigation shortcuts

Main renamed the binding condition `desktop` to `desktopBindings` and the
test context `isDesktop` to `usesDesktopBindings` (#8625). Rebase the
Cmd+Shift+[ / ] tab shortcuts onto that vocabulary.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-13 12:40:17 +07:00
paseo-bot[bot]andRyan Swift c558af91eb fix(usage): show Copilot premium request quota (#8489)
The usage source discarded quota snapshots, leaving the card with only a
plan and reset date. Publish finite premium usage through the existing
window interface and retain its remaining request count.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ryan Swift <ryan@mlh.io>
2026-11-13 12:13:38 +07:00
Mohamed Boudra 7791dcee3b Docs: document the public iOS TestFlight beta on the Updates page (#9553)
* Document the public iOS TestFlight beta and how to get betas on every platform

* Show the TestFlight beta on the download page's Beta view
2026-11-13 11:20:12 +07:00
e532a24327 fix(server): start cross-host children in their caller's mode (#9512)
When an agent creates a child on another connected host without passing
settings.modeId, the destination cannot see the caller, so the child fell
back to the provider default instead of inheriting the caller's mode the
way a same-host child does. A bypass caller's remote child then asked for
approval on every tool call.

Resolve the mode on the sending host before routing, as background
already is: an omitted mode with a same-provider caller forwards the
caller's current mode. Explicit modes still win, and cross-provider or
caller-less creates still leave the mode to the destination.

Co-authored-by: Orcun Simsek <1612171+ortschun@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:57:45 +00:00
paseo-bot[bot] 1aba2a5f23 Measure composer pills after the compact layout replaces them (#9546)
Resizing to a compact width mounts a new composer, so boundingBox() on a
pill could resolve the wide layout's element and return null once it
unmounted. Wait for the compact header, then poll each pill's width.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-13 03:42:48 +00:00
aead84bf0f Interrupting sends no longer fail with "already has an active run" (#9537)
* Interrupting sends no longer fail with "already has an active run"

Two sends that interrupt the same running agent raced: the first left the
foreground lane after cancelling and before reserving its new run, so the
second send's cancel ran in that gap, saw the agent still marked running,
and tracked a phantom run that made the first send's start throw.

Replacements now queue per agent in arrival order, and each cancels and
reserves its run inside one foreground mutation. A replacement that finds a
run still starting waits for it outside the lane, so Stop still reaches it.

Co-authored-by: Michael Wu <421504+michaelmwu@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Interrupting sends still recover an agent whose turn never starts

A replacement now waits for a starting run only up to the rescue
timeout, then cancels it the way main did, so sending another message
still unsticks an agent whose provider never acknowledges a start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A queued replacement acts on the agent as it is when it gets the lane

Resolve the managed agent inside the foreground mutation instead of
before queueing, so a session reload while the send waits cannot leave
it writing state to, or checking steer ownership against, the old agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Wu <421504+michaelmwu@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:09:32 +00:00
paseo-bot[bot] 7980ad008b Keep keystrokes typed while a terminal restores its screen (#9533)
* fix(app): keep keystrokes typed while a terminal restores its screen

When a terminal re-attaches, for example after the host reconnects, the
browser terminal replays the screen snapshot and ignores everything xterm
emits until the replay is parsed, so replies to queries in the restored
output don't reach the shell. That also threw away what the user typed
during the replay: the first characters typed right after a reconnect
were lost.

xterm emits both through the same callback. Replies only come out of
parsing a write, so data emitted while a keyboard, input or paste event
is dispatching on the terminal is the user's, and it now goes through.

* fix(app): keep pastes suppressed while a terminal restores its screen

Until the replayed snapshot restores bracketed paste mode, a multi-line
paste would reach the shell unbracketed and run line by line. Pastes
during a replay are dropped as before; only typed input goes through.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-13 03:01:35 +00:00
7c35ad90b9 Start an edited agent draft after its first start failed (#9534)
* test(app): reproduce edited draft retries after a failed agent start

Resubmitting an agent draft after its start failed reuses the draft ID as
the creation key with different inputs, and the daemon rejects it with
agent_request_key_conflict (or workspace_request_key_conflict from New
workspace).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): start an edited agent draft after its first start failed

The draft sent its draft ID as the creation key on every submit. After a
failed start the daemon keeps that key's request, so resubmitting an edited
draft was rejected with agent_request_key_conflict. The key now names the
draft's exact request: a retry resumes it, an edit starts a new one.

A retry after New workspace's agent failed to start creates the agent in
the workspace that is already ready, instead of replaying workspace
provisioning with a different agent.

Co-authored-by: michaelmwu <421504+michaelmwu@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: michaelmwu <421504+michaelmwu@users.noreply.github.com>
2026-11-13 02:05:34 +00:00
9eccb4722b Model picker profile buttons respond to taps on Android tablets (#9526)
* fix(app): keep the model picker's profile action tappable on Android

On Android tablets the model list scrolls inside an independent viewport whose native scroll gesture cancels a plain Pressable's touches. Rows use the gesture-aware pressable; the profile + and the Profiles pencil were left as plain Pressables. Route both through the same gesture path.

* test(app): cover the tablet model row's profile action on Android

Adds an agent-device journey that taps a model row's `+` in the New
workspace picker on an Android tablet and waits for the seeded profile
form. It fails on main, where the tap is swallowed, and passes with the
gesture-aware profile action.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): make the tablet profile journey follow the documented setup

The journey now starts from a remembered model like its sibling, so it
runs after model-tablet.android.ad, and asserts that closing the profile
form leaves the remembered model selected, so a tap on `+` that also
selected the row fails it.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): keep the no-profiles requirement for the tablet profile journey

With a saved profile the picker opens on its root view, which has no
Back, so the journey needs a host with no agent profiles.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): hide the model row's profile action through its style

IconActionButton took a pointerEvents prop that only its web branch
applied, so a native caller hiding the action would get an invisible
button that still took taps. The hidden style now carries
pointerEvents: "none", which every pressable branch already applies,
and the prop is gone. The web journey checks the plus ignores taps
until its row is hovered.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: moevis <moevery@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:00:22 +00:00
LiebeLee 536846eb60 fix(omp): persist the resumed session identity when importing (#8854)
OMP import listings return a session file path, and the import helper stored
that path as both sessionId and nativeHandle. Records then carried a path in
sessionId while runtimeInfo carried the real id, which breaks identity-based
lookups and makes a later import of the same file look unrelated.

Prefer the resumed session's own describePersistence() identity and keep the
listed handle as nativeHandle, merging metadata so provider-specific fields
survive.
2026-11-13 00:02:23 +00:00
cc16ac6ac4 fix(app): keep line breaks when copying a selection that crosses part of a code block (#9515)
* Preserve partial code blocks in assistant selection copy

* test(app): cover partial code copies in the real assistant selection journey

Copying from a paragraph into part of a highlighted code block, or from
partway through a block out to the next paragraph, now has browser
journey coverage. On main both copies join the code lines into one line
and drop the fence.

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): keep indented code lines inside a copied list item

Copying a list item that holds a code block indented only the code lines
that start with a non-space character, so an indented line fell outside
the item and broke the fence. Indent every non-empty line of the item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:52:13 +00:00
paseo-bot[bot] fd9aebd622 fix(app): close a bottom sheet hidden before it finished opening (#9519)
A sheet hidden right after it was presented, such as the context usage
sheet when a plugin focuses the composer, could stay open. The dismiss
reached the sheet before it had measured its layout, and the library
drops a close it cannot position yet. The sheet then finished opening,
and the visibility tracker took that as presented while its owner
wanted it hidden.

The tracker now dismisses again when a sheet reports an open position
while its owner wants it hidden.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 21:49:05 +00:00
RockandClaude Opus 5.5 21709812bb fix(codex): retry an oversized history page with smaller pages (#9432)
thread/items/list asks for 100 items a page. On image-heavy threads a page
can exceed the app-server's line cap, and the history stopped at the first
such page with a "too large to load" warning, dropping every later item
even when each one fits. The reader now halves the page and retries the
same cursor, and shows the warning only when a single item cannot fit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:02:10 +00:00
d426088c3f Stop agent updates re-rendering the workspace screen, favicon, and command center (#9497)
* Stop hidden surfaces rebuilding the agent directory on every update

The favicon is mounted at the root of every web and Electron session, and it
derived the whole aggregated agent list to answer three questions: is any agent
running, does any want attention, does any need input. The aggregated list
allocates a projection per agent, deep-compares it, sorts the whole host and
rebuilds its identity cache, and useAggregatedAgents re-ran all of that on
every agent update on any host. The dock badge did the same for workspaces: it
materialised every workspace status and deep-compared the array for a number.

Both now read a scalar selector: selectFaviconStatus walks the agent maps and
returns one of three strings, and selectMacDockBadgeCount counts actionable
workspaces without building a list. A store write that leaves those values
alone no longer re-renders the root component.

useAggregatedAgents also treated `demand` as "hold the directory subscription"
but still subscribed to every host's agent map, so the closed command center
(useAggregatedAgents({ demand: open })) rebuilt the full list on every update
too. With demand false the hook now returns a stable empty result and stays off
the maps.

Measured on the many-agent-tabs harness (5 tabs, 20 s windows, agent_update
churn from a background host): the root favicon component rendered 1554 times
in 20 s before this change and 1 time after.

* Complete the host runtime mock in the favicon test

* Keep the profiling harness out of the fix

The harness scans every process environment to find the daemon. Whether
Paseo carries a renderer CPU harness, and how it samples processes, is a
separate decision from the rendering fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop re-rendering the workspace screen on unrelated agent updates

useOpenAgentTabLabels subscribed the workspace screen to the host's whole
agent map. Every agent update on the host replaces that map, so each
update re-rendered the open workspace screen, its split panes and tab
menus. Subscribe only to the agents that have open tabs.

Co-authored-by: jl97mcad <56251999+jl97mcad@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test render isolation through pure selectors and the browser

Replace the JSDOM hook tests and module mocks with pure selector tests
and a browser test that updates an agent in another workspace and checks
the open workspace screen does not re-render. Memoize the favicon and
dock badge selectors on the session maps they read, so unrelated store
writes skip the scan, and pass directory demand as an object.

Co-authored-by: jl97mcad <56251999+jl97mcad@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: jl97mcad <56251999+jl97mcad@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:04:24 +00:00
Don Phillips b5c25e5c10 fix(opencode): discover V2 tools from bound session catalogs (#9426)
Fetch tool definitions for the bound session instead of the global manifest so plugin-contributed tools can be registered and invoked. Preserve global discovery for legacy callers and existing session filtering and execution authorization.

Cover session-only tool discovery, invocation, child inheritance, disabled and unrelated sessions, unbinding, and plugin reload.
2026-11-12 17:56:32 +00:00
paseo-bot[bot]andFluidXin 525ee7f222 fix(app): keep Mermaid chart axes from overlapping and clipping (#9483)
* fix(app): render Mermaid diagrams in an unscaled offscreen staging container

* fix(app): keep gantt charts at a fixed width in the Mermaid staging container

Rendering into the oversized staging container gave gantt charts its
16384px width, because Mermaid sizes a gantt chart from the element it
renders in. The host then fit that width into the message column and the
chart drew a few pixels tall. Give gantt charts the 1200px width Mermaid
uses when there is no container width, so they report the same size
whatever frame the host gives the runtime.

Co-authored-by: FluidXin <266379324+FluidXin@users.noreply.github.com>

* fix(app): keep gantt charts as wide as the Mermaid frame

Pinning gantt charts to 1200px shrank them on iOS and Android, where the
measuring frame is as wide as the message column and gantt charts used
to fill it. Use the frame's own width instead, which is what Mermaid
read from the page before rendering moved into the staging container.

Co-authored-by: FluidXin <266379324+FluidXin@users.noreply.github.com>

---------

Co-authored-by: FluidXin <266379324+FluidXin@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 17:11:35 +00:00
paseo-bot[bot]andPhieu Dinh 065c487a90 fix(server): keep workspace scripts when paseo.json checks are slow (#9477)
* fix(server): answer workspace listings from the last paseo.json snapshot

Every workspace listing and update builds a descriptor per workspace, and each descriptor read that workspace's paseo.json through a stat inside the daemon-wide two-slot pool, with a 5 s timeout that counted the queue wait. With many workspaces and a few clients listing, the last workspaces timed out, dropped their scripts, and the timed-out reads still ran, so other requests waited minutes.

The projection path now answers from the last snapshot and re-checks it behind the caller once it is 2 s old (a clock stepped backwards counts as stale). Edits, worktree setup and metadata prompts keep the fresh read. The snapshot cap rises to 1024 roots so a listing over more roots than the cap does not evict the next root it needs.

* fix(server): check paseo.json on every listing, answer the last config only when the check is late

Answering listings from the snapshot showed the previous scripts on the
first listing after an external edit to paseo.json, such as a branch
switch or an agent editing the file, and nothing re-sent the update
once the background check saw the change. A file that stopped being
admissible also kept its old scripts indefinitely.

Listings and script status now check paseo.json as before. When the
check does not finish within the 5 s budget, they answer the last
config read for that root instead of no scripts. Edits and execution
keep failing on a late check.

Co-authored-by: Phieu Dinh <phieu@fiu.one>

* fix(server): keep the saved paseo.json as the last known config after an in-app save

Saving through the project config editor dropped the snapshot, so a
late check on the next listing showed no scripts for that workspace.
The save now records the config it wrote.

* fix(server): record the saved paseo.json without another filesystem read

Re-reading the file after the rename made a completed save wait on an
unbudgeted read. The save now records the config and file stats it
already has.

---------

Co-authored-by: Phieu Dinh <phieu@fiu.one>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 16:04:52 +00:00
paseo-bot[bot]andTianyu Yang 4df5307eaa fix: keep failed ACP tools valid in conversation history (#9467)
Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
2026-11-12 15:14:46 +00:00
paseo-bot[bot] aa7cabb455 test(app): cover composer recall in one journey (#9409)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 15:08:41 +00:00
paseo-bot[bot]andmoevis 9e607a3aa3 fix(app): restore physical arrow keys in Android terminals (#9407)
* fix(app): send hardware arrow keys to the terminal on Android

TextInput.onKeyPress is built from the IME input connection, so physical arrows never reach it and fell through to the hidden input as caret movement. The hardware module now claims arrows while the terminal input (nativeID) has focus, and the input forwards them to the PTY with their modifiers.

* test(app): guard Android terminal hardware arrow delivery

Exercise physical arrow ownership through a real Android window and editable, including repeats, modifiers and ordinary text-field editing. The terminal case fails on main because Left moves the hidden caret; the following typed character is then discarded as a replacement.

Co-authored-by: moevis <moevery@gmail.com>

---------

Co-authored-by: moevis <moevery@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 14:09:19 +00:00
paseo-ai[bot] 68818067f2 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-11 18:49:19 +00:00
Mohamed Boudra 2240d4effe chore(release): reconcile 0.16.0-beta.2 into main (#9422)
* docs(changelog): describe 0.16.0-beta.2

Folds the nine user-facing changes merged since beta.1 into the series entry and renames its heading. Highlights are unchanged.

* chore(release): cut 0.16.0-beta.2
2026-11-12 01:40:42 +07:00
137 changed files with 3319 additions and 682 deletions
+10 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
## 0.16.0-beta.2 - 2026-11-11
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
@@ -30,6 +30,8 @@
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
### Fixed
@@ -37,6 +39,7 @@
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- Fixed a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
@@ -48,6 +51,7 @@
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- Fixed unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
@@ -73,11 +77,13 @@
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
- Fixed Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
- Fixed resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
@@ -109,10 +115,13 @@
### Plugins
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
- Fixed a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
+10 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
## 0.16.0-beta.2 - 2026-11-11
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
@@ -30,6 +30,8 @@
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
### 修复
@@ -37,6 +39,7 @@
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
@@ -48,6 +51,7 @@
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
@@ -73,11 +77,13 @@
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
@@ -109,10 +115,13 @@
### 插件
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
+9
View File
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
from `packages/app/android`:
```bash
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
### Diagnostic logs without ADB
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
+15 -3
View File
@@ -20,9 +20,16 @@ settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
that must bypass the focused text control: composer Return and the top overlay's
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
after the focused text control has already handled the press, so the pipeline's
`preventDefault` cannot stop a key from typing. A key the text control would type
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
active in the current focus scope, such as Shift+Tab in the composer.
`sources/native.ts` computes the claims from the published bindings and recomputes
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
text controls do not type them. Android takes no claims: a focused `EditText`
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
press is still observed by the window.
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
@@ -85,6 +92,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
Terminal arrows never pass through the shortcut pipeline. The Android hardware
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
+10 -1
View File
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
previous file also keeps whatever head it had recorded, which can be an older branch than the one
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
the newest file or the newest message wins. Import listings hand back a file path, so persist the
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
handle as both.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
+2 -1
View File
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The website download page defaults to GitHub's latest published **stable** release.
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
+1 -1
View File
@@ -1 +1 @@
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/highlight": "0.16.0-beta.1",
"@getpaseo/plugin": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/highlight": "0.16.0-beta.2",
"@getpaseo/plugin": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Crossing into part of the block keeps the selected code lines as a fenced block.
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
);
// So does starting partway through the block and leaving it.
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Selecting a whole line runs off its end into the newline node. A trailing
// newline auto-executes the line when it is pasted into a terminal.
await selectAssistantAcrossCodeLines(page, "const", " ");
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
test.use({ hasTouch: true });
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render three paragraphs.",
initialPrompt: "Render structured text.",
featureValues: {
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
mockAssistantResponse: [
"First paragraph.",
"",
"- List item.",
"",
"> Quote text.",
"",
"```ts",
"const answer = 1;",
"```",
"",
"Third paragraph.",
].join("\n"),
},
});
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("List item.");
expect(selected).toContain("Quote text.");
expect(selected).toContain("const answer = 1;");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { submitMessage } from "../support/helpers/composer";
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
await expectWorkspaceHeaderVisible(page);
for (const [index, pill] of [tasks, subagents, changes].entries())
await expect
.poll(async () => (await pill.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
@@ -6,6 +6,7 @@ import {
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
});
await test.step("recall a slash message without opening its picker, then edit it", async () => {
await expectComposerDraft(page, "");
const reply = await session.client.waitForFinish(session.agentId, 15_000);
expect(reply.status).toBe("idle");
await expectVisibleAgentSurfacesIdle(page);
await sendAndWaitForMessage(page, "/tdd", 4);
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
});
} finally {
await session.cleanup();
}
@@ -127,8 +127,42 @@ for (const scenario of [
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
creation,
startup,
page,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Start the original task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
}
test("an edited agent draft starts after its first start failed", async ({
creation,
startup,
page,
}) => {
await creation.openAgentDraft();
await startup.fail();
await creation.submitPrompt("Start the original task.");
await creation.expectStartupFailure();
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
for (const entry of [
{
name: "agent draft",
@@ -159,7 +193,9 @@ for (const entry of [
await entry.settled(creation);
await creation.expectCreatedAgentError();
await creation.expectPromptBeforeError(prompt);
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
await page.screenshot({
path: testInfo.outputPath("created-agent-prompt-error.png"),
});
await creation.reloadAgent();
await creation.expectPromptBeforeError(prompt);
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const terminalTabs = page
.locator('[data-testid^="workspace-tab-terminal_"]')
.filter({ visible: true });
await clickNewTerminal(page);
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
await clickNewTerminal(page);
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
const first = terminalTabs.nth(0);
const second = terminalTabs.nth(1);
await expect(second).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Alt+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Alt+Shift+BracketRight");
await expect(second).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,56 @@
import { test, expect } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "e2e-fast-stream",
});
return agent.id;
}
test.describe("Updates to agents in other workspaces", () => {
let open: SeededWorkspace;
let other: SeededWorkspace;
test.afterEach(async () => {
await open?.cleanup();
await other?.cleanup();
});
test("do not re-render the open workspace screen", async ({ page }) => {
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
const openAgentId = await createMockAgent(open, "Open agent");
const otherAgentId = await createMockAgent(other, "Other agent");
await recordReactRenders(page);
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
await expect(
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(2_000);
await resetReactRenders(page);
await other.client.sendAgentMessage(otherAgentId, "update");
await other.client.waitForFinish(otherAgentId, 60_000);
// The favicon follows every agent on the host, so it renders when the other agent runs and
// settles. That confirms the updates reached this page.
await expect
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
.toBeGreaterThan(0);
const renders = await readReactRenders(page);
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
});
});
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
--artifacts-dir /tmp/paseo-model-tablet-qa
```
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
```
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
@@ -0,0 +1,22 @@
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${FORM_URL}"
wait "label=\"Workspace project\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"combobox-desktop-container\"" 10000
# A remembered model opens its provider's list. Return to the provider page first.
press "label=\"Back\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
# The row's profile action sits inside Android's independent scroll viewport.
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
wait "text=\"Add agent profile\"" 5000
# Closing the form keeps the remembered model: the tap did not also select the row.
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
const action = pickerViewport(page).getByTestId(
`model-create-profile-${input.provider}-${input.modelId}`,
);
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
await expect(action).toHaveCSS("pointer-events", "none");
await modelRow(page, input).hover();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
await expect(action).toHaveCSS("pointer-events", "auto");
await action.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
+6 -1
View File
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
const evictionWorkspaceIds = new Set<string>();
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -8,9 +8,12 @@ android {
defaultConfig {
versionCode 1
versionName '0.1.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
}
dependencies {
implementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="android.app.Activity" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,81 @@
package sh.paseo.keyboard
import android.app.Activity
import android.content.Intent
import android.view.KeyEvent
import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Exercises the real Android window and editable that receive physical keys. */
@RunWith(AndroidJUnit4::class)
class TerminalArrowTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
instrumentation.runOnMainSync {
activity?.let {
HardwareKeyboard.release(it.window)
it.finish()
}
}
}
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
withInput(terminal = true) { input, host ->
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
input.setSelection(3)
for (repeat in 0..2) {
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
}
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
}
}
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
assertEquals("abcx", input.text.toString())
}
}
@Test fun physicalArrowsStillEditOtherTextFields() {
withInput(terminal = false) { input, host ->
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
assertEquals(2, input.selectionStart)
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
assertEquals("abxc", input.text.toString())
}
}
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
val host = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = host
lateinit var input: EditText
instrumentation.runOnMainSync {
input = EditText(host)
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
host.setContentView(input)
input.setText("abc")
input.requestFocus()
input.setSelection(3)
HardwareKeyboard.observe(host.window)
}
instrumentation.waitForIdleSync()
instrumentation.runOnMainSync {
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
check(input, host)
}
}
}
@@ -10,18 +10,24 @@ import android.view.KeyEvent
import android.view.Window
import android.widget.EditText
import android.view.inputmethod.BaseInputConnection
import com.facebook.react.R
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.ReactContext
import com.facebook.react.interfaces.ExtraWindowEventListener
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
// Matched against the hidden terminal input's `nativeID`
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
internal object HardwareKeyboard {
var module: PaseoHardwareKeyboardModule? = null
@Volatile var overlayKeys: Set<String> = emptySet()
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
val dispatcher = WindowKeyDispatcher<KeyEvent>()
private val consumedPresses = mutableSetOf<Int>()
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
fun observe(window: Window) {
if (windows.containsKey(window)) return
@@ -38,7 +44,7 @@ internal object HardwareKeyboard {
consumedPresses.clear()
}
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
val code = code(event.keyCode)
@@ -64,8 +70,12 @@ internal object HardwareKeyboard {
))
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey
// Without this the hidden input consumes the arrows as caret movement and
// its next text change stops being a plain append. Modifiers stay
// forwarded: Alt/Shift+arrow are terminal editing keys.
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey || terminalOwnsKey
}
private fun code(keyCode: Int): String = when (keyCode) {
@@ -107,9 +117,15 @@ internal object HardwareKeyboard {
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val editable = (window.currentFocus as? EditText)?.text
val focus = window.currentFocus
val editable = (focus as? EditText)?.text
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
return HardwareKeyboard.dispatcher.dispatch(
event,
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
{ previous.dispatchKeyEvent(event) },
)
}
}
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
private var claimedShortcuts: [ClaimedShortcut] = []
/// UIKit spells non-character keys as sentinel input strings rather than as the
/// character they would type. JS sends the DOM `code` and the mapping lives
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
"Enter": "\r",
]
/// A shortcut the window takes from the focused text control: Shift+Tab would
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
private struct ClaimedShortcut {
let input: String
let modifierFlags: UIKeyModifierFlags
}
struct ClaimedShortcutRecord: Record {
@Field var code: String = ""
@Field var shift: Bool = false
@Field var alt: Bool = false
}
private let claimableKeyInputs: [String: String] = [
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
]
/// UIKit matches a key command against the unmodified character, so
/// Option+Shift+[ is registered as "[" with both modifier flags.
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
let code = record.code
let input: String?
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
else { input = claimableKeyInputs[code] }
guard let input else { return nil }
var modifierFlags: UIKeyModifierFlags = []
if record.shift { modifierFlags.insert(.shift) }
if record.alt { modifierFlags.insert(.alternate) }
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
}
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
}
}
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
let claimed = shortcuts.compactMap(claimedShortcut)
DispatchQueue.main.async {
claimedShortcuts = claimed
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
claimedShortcuts = []
}
}
}
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
{
commands.append(makeSubmitCommand())
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
if !isComposingText() {
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
}
return commands
}
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
return command
}
/// The window's press stream still emits the shortcut; the command only keeps
/// the key away from the text control.
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
let command = UIKeyCommand(
input: shortcut.input,
modifierFlags: shortcut.modifierFlags,
action: #selector(handleHardwareKeyboardShortcut(_:))
)
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -759,6 +759,56 @@ describe("assistant selection copy inside highlighted code", () => {
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
it("keeps line breaks when a quote selection ends partway through code", () => {
const message = mountHighlighted();
const quote = document.createElement("div");
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
quote.textContent = "Quoted context";
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
const content = copyBetween(
[tokenText(message, "Quoted context"), 0],
[tokenText(message, " (answer)"), 9],
);
expect(content?.plainText).toBe(
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
);
});
it("keeps indented code lines inside their list item", () => {
const host = document.createElement("div");
host.innerHTML = [
'<div data-testid="assistant-message">',
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
highlightedCodeBlock("typescript"),
"</div></div></div></div>",
].join("");
document.body.append(host);
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
expect(content?.plainText).toBe(
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
);
});
it("keeps line breaks when a selection starts partway through code", () => {
const message = mountHighlighted();
const content = copyBetween(
[tokenText(message, " answer"), 1],
[tokenText(message, "After the block."), "After the block.".length],
);
expect(content?.plainText).toBe(
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});
describe("semantic library output", () => {
@@ -81,7 +81,8 @@ turndown.addRule("compactListItem", {
const item = content
.replace(/^\n+/, "")
.replace(/\n+$/, "\n")
.replace(/\n(?=\S)/g, "\n ");
// Every non-empty line, so indented code lines stay inside the item.
.replace(/\n(?=[^\n])/g, "\n ");
const parent = node.parentElement;
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
@@ -359,6 +360,9 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
if (isCrossBoundaryCodeElement(range, element, tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
if (
(isSelectableSemantic || element.tagName === "A") &&
@@ -386,6 +390,11 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return hasSelectedAllContents(range, element, tag === "li");
}
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
return isCode && !selectionStaysInsideElement(range, element);
}
function selectionStaysInsideElement(range: Range, element: Element): boolean {
return (
range.startContainer !== element &&
@@ -63,10 +63,25 @@ const html = `<!doctype html>
#diagram svg {
display: block;
}
/* Keep Mermaid's temporary render container unscaled when the host mounts this frame
at 0x0 or 240x240 before the diagram's own dimensions are known. */
#staging-clip {
position: fixed;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
#staging {
width: 16384px;
height: 16384px;
}
</style>
</head>
<body>
<div id="diagram"></div>
<div id="staging-clip" aria-hidden="true"><div id="staging"></div></div>
<script>${js}</script>
</body>
</html>`;
@@ -40,6 +40,9 @@ function initializeMermaid(colorScheme: "light" | "dark"): void {
],
flowchart: { htmlLabels: false },
class: { htmlLabels: false },
// A gantt chart takes the width of the element it renders in, which is the oversized staging
// container. Keep it as wide as this frame, which on native is the message column.
gantt: { useWidth: document.body.clientWidth },
});
}
@@ -97,7 +100,15 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
const staging = document.getElementById("staging");
if (!staging) {
throw new Error("Missing Mermaid staging container");
}
const { svg } = await mermaid.render(
`paseo-mermaid-${message.revision}`,
message.source,
staging,
);
if (message.revision !== latestRevision) {
return;
}
File diff suppressed because one or more lines are too long
@@ -29,9 +29,15 @@ function waitForRuntimeMessage(
});
}
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
async function mountRuntime(
size?: { width: number; height: number },
options?: { allowSameOrigin?: boolean },
): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
if (options?.allowSameOrigin) {
frame.sandbox.add("allow-same-origin");
}
if (size) {
frame.style.width = `${size.width}px`;
frame.style.height = `${size.height}px`;
@@ -112,6 +118,103 @@ describe("Mermaid sandbox runtime", () => {
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
});
/**
* MermaidFenceHost renders the first revision inside a 240x240 measuring frame (or 0x0 before
* ResizeObserver settles), then resizes the frame to the reported diagram size without
* re-rendering. Diagrams such as xychart-beta set `width="100%"` and `viewBox="0 0 700 500"`
* before measuring axis text with `getBoundingClientRect()`, so rendering directly in a narrow
* frame shrinks measured glyph boxes and leaves the Y-axis title colliding with tick labels and
* X-axis labels clipped at the bottom edge.
*/
it("preserves unscaled SVG layout when rendered inside a narrow or zero-sized frame", async () => {
const source = [
"xychart-beta",
' title "Throughput by Stage (req/s)"',
' x-axis ["v1-base", "v2-parse", "v3-batch", "v4-cache", "v5-simd", "v6-final"]',
' y-axis "Throughput (req/s)" 92.5 --> 93.6',
" bar [92.7, 92.8, 93.1, 93.2, 93.3, 93.5]",
].join("\n");
const zeroFrame = await mountRuntime({ height: 0, width: 0 }, { allowSameOrigin: true });
const measuringFrame = await mountRuntime(
{ height: 240, width: 240 },
{ allowSameOrigin: true },
);
const fullFrame = await mountRuntime({ height: 500, width: 700 }, { allowSameOrigin: true });
await render(zeroFrame, { revision: 1, source });
await render(measuringFrame, { revision: 1, source });
await render(fullFrame, { revision: 1, source });
for (const frame of [zeroFrame, measuringFrame]) {
frame.style.width = "700px";
frame.style.height = "500px";
const doc = frame.contentDocument;
if (!doc) {
throw new Error("Expected inspectable runtime document");
}
const svgRect = doc.querySelector("#diagram svg")?.getBoundingClientRect();
const yTitleRect = doc.querySelector(".left-axis .title text")?.getBoundingClientRect();
const yLabels = Array.from(doc.querySelectorAll(".left-axis .label text"));
const xLabels = Array.from(doc.querySelectorAll(".bottom-axis .label text"));
expect(svgRect).not.toBeUndefined();
expect(yTitleRect).not.toBeUndefined();
expect(yLabels.length).toBeGreaterThan(0);
expect(xLabels.length).toBeGreaterThan(0);
for (const label of yLabels) {
expect(yTitleRect!.right).toBeLessThan(label.getBoundingClientRect().left);
}
for (const label of xLabels) {
expect(label.getBoundingClientRect().bottom).toBeLessThanOrEqual(svgRect!.bottom);
}
}
function axisOrigin(frame: HTMLIFrameElement): { leftAxisX: number; bottomAxisY: number } {
const doc = frame.contentDocument;
const leftPath = doc?.querySelector(".left-axis .axisl-line path")?.getAttribute("d") ?? "";
const bottomPath =
doc?.querySelector(".bottom-axis .axis-line path")?.getAttribute("d") ?? "";
const leftMatch = leftPath.match(/^M\s+([\d.]+),/);
const bottomMatch = bottomPath.match(/^M\s+[\d.]+,([\d.]+)/);
if (!leftMatch || !bottomMatch) {
throw new Error(`Unexpected axis paths: ${leftPath} | ${bottomPath}`);
}
return {
leftAxisX: Number.parseFloat(leftMatch[1]),
bottomAxisY: Number.parseFloat(bottomMatch[1]),
};
}
const fullOrigin = axisOrigin(fullFrame);
for (const candidate of [measuringFrame, zeroFrame]) {
const origin = axisOrigin(candidate);
expect(origin.leftAxisX).toBeCloseTo(fullOrigin.leftAxisX, 3);
expect(origin.bottomAxisY).toBeCloseTo(fullOrigin.bottomAxisY, 3);
}
});
/**
* A gantt chart takes the width of the element Mermaid renders it in. Rendering inside the large
* offscreen container must keep it as wide as the frame, which on native is the message column.
*/
it("renders a gantt chart as wide as the frame the host gives it", async () => {
const source = [
"gantt",
" title Release plan",
" dateFormat YYYY-MM-DD",
" section Build",
" Design :a1, 2024-01-01, 10d",
" Implement :after a1, 20d",
].join("\n");
const widths = [];
for (const width of [240, 350, 900]) {
const frame = await mountRuntime({ height: 240, width });
widths.push(renderedSize(await render(frame, { revision: 1, source })).width);
}
expect(widths).toEqual([240, 350, 900]);
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
+64 -25
View File
@@ -106,29 +106,76 @@ function ProviderSettingsAction({
);
}
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
const { t } = useTranslation();
/**
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
* never open on mobile, so native renders the shared gesture-aware pressable
* instead of a plain Pressable: inside the model list's independent scroll
* viewport (Android, wide windows) the native scroll gesture cancels a plain
* Pressable's touches and the button would never fire.
*/
function IconActionButton({
accessibilityLabel,
testID,
onPress,
style,
children,
}: {
accessibilityLabel: string;
testID: string;
onPress: () => void;
style?:
| StyleProp<ViewStyle>
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
children: React.ReactNode;
}) {
if (isNative) {
return (
<ModelBrowserPressable
onPress={onPress}
hitSlop={8}
style={style}
accessibilityLabel={accessibilityLabel}
testID={testID}
>
{children}
</ModelBrowserPressable>
);
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
hitSlop={8}
style={iconButtonStyle}
style={style}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
accessibilityLabel={accessibilityLabel}
testID={testID}
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
const { t } = useTranslation();
return (
<IconActionButton
onPress={onPress}
style={iconButtonStyle}
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
</IconActionButton>
);
}
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
null,
);
@@ -637,24 +684,14 @@ function ModelRowProfileAction({
[visible],
);
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={label}
testID={testID}
>
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
<IconActionButton
onPress={onPress}
style={pressableStyle}
accessibilityLabel={label}
testID={testID}
>
{children}
</IconActionButton>
);
}
@@ -1690,8 +1727,10 @@ const styles = StyleSheet.create((theme) => ({
rowIconButtonPressed: {
backgroundColor: theme.colors.surface1,
},
// Hidden in style so every pressable branch that applies the style also stops taking taps.
profileActionHidden: {
opacity: 0,
pointerEvents: "none",
},
emptyState: {
padding: theme.spacing[4],
@@ -51,10 +51,7 @@ import {
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
type NativeTerminalKey,
} from "../terminal/native-renderer/terminal-key-events";
import { type NativeTerminalKeyEvent } from "../terminal/native-renderer/terminal-key-events";
import {
TerminalInput,
type TerminalInputHandle,
@@ -511,9 +508,9 @@ function NativeTerminalEmulator({
);
const handleNativeTerminalKey = useCallback(
(key: NativeTerminalKey) => {
(event: NativeTerminalKeyEvent) => {
claimActiveTerminalSizeForAction("key");
forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey });
callbacksRef.current.onTerminalKey?.(event);
},
[claimActiveTerminalSizeForAction],
);
@@ -88,6 +88,18 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("dismisses again when a sheet hidden while opening finishes opening anyway", () => {
const { sheet, tracker } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
// The owner hides the sheet before its open animation reports in; the sheet still lands open.
tracker.syncDesired({ visible: false });
tracker.handleSheetIndexChange(0);
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }, { type: "dismiss" }]);
});
it("waits to present until the sheet controller becomes available", () => {
const { sheet, tracker } = setup();
tracker.syncDesired({ visible: true });
@@ -120,6 +120,11 @@ export function createBottomSheetVisibilityTracker(opts: {
if (phase === "presenting" || phase === "dismissing") {
setPhase("presented");
}
// A dismiss sent before the sheet measured its layout is dropped, and the sheet still
// finishes opening. The owner wants it hidden, so it goes again.
if (!visible && isEnabled !== false) {
dismiss();
}
return;
}
if (phase === "presenting" || phase === "presented") {
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { draftCreationKey } from "./creation-key";
const request = {
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
workspaceId: "workspace-1",
initialPrompt: "Start the original task.",
clientMessageId: "draft-1:initial-message",
};
describe("draftCreationKey", () => {
it("keeps the key for a retry of the same request, whatever its key order", () => {
const reordered = {
clientMessageId: request.clientMessageId,
initialPrompt: request.initialPrompt,
workspaceId: request.workspaceId,
config: { model: "gpt-6", cwd: "/repo", provider: "codex" },
};
expect(draftCreationKey("draft-1", reordered)).toBe(draftCreationKey("draft-1", request));
});
it.each([
{ name: "prompt", edited: { ...request, initialPrompt: "Start the edited task." } },
{ name: "model", edited: { ...request, config: { ...request.config, model: "gpt-6-mini" } } },
])("gives an edited $name a new key on the same draft", ({ edited }) => {
const key = draftCreationKey("draft-1", edited);
expect(key).not.toBe(draftCreationKey("draft-1", request));
expect(key.startsWith("draft-1:")).toBe(true);
});
});
@@ -0,0 +1,29 @@
/**
* The creation key for a draft's request. The daemon keeps one request per key, so retrying the
* same draft resumes its creation, while an edited draft is a new request with its own key.
*/
export function draftCreationKey(draftId: string, request: object): string {
return `${draftId}:${hash(canonicalJson(request))}`;
}
function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, entry: unknown) =>
entry && typeof entry === "object" && !Array.isArray(entry)
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)))
: entry,
);
}
// 53-bit cyrb53: distinguishes the edits of one draft without WebCrypto, which needs HTTPS.
function hash(text: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
h1 = Math.imul(h1 ^ code, 2654435761);
h2 = Math.imul(h2 ^ code, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
@@ -47,6 +47,7 @@ import {
} from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import { draftCreationKey } from "@/composer/draft/creation-key";
interface AutoSubmitConfig {
provider: string;
@@ -164,8 +165,7 @@ async function submitDraftCreateRequest(input: {
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const imagesData = await encodeImages(images);
const options = {
idempotencyKey: input.draftId,
const request = {
config,
workspaceId,
initialPrompt: text,
@@ -174,8 +174,10 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
const result = await client.createAgent({
...request,
idempotencyKey: draftCreationKey(input.draftId, request),
});
return {
agentId: result.id,
@@ -1,10 +1,11 @@
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useAllDirectoryDemand } from "./use-directory-demand";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -19,6 +20,9 @@ export interface AggregatedAgentsResult {
refreshAll: () => void;
}
const NO_AGENT_MAPS: Record<string, Map<string, Agent> | undefined> = Object.freeze({});
const NO_AGENTS: AggregatedAgent[] = [];
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
demand?: boolean;
@@ -28,19 +32,21 @@ export function useAggregatedAgents(options?: {
const includeArchived = options?.includeArchived ?? false;
const demand = options?.demand ?? true;
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
useEffect(() => {
if (!demand) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [demand, runtime, serverIds]);
useAllDirectoryDemand({ serverIds, enabled: demand });
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// The caller has said it does not need the directory, so stay off the agent maps entirely:
// every map change otherwise re-renders this hook and rebuilds the whole list for a surface
// that is not on screen (the command center while it is closed).
const sessionAgents = useSessionStore(
useShallow((state) => {
if (!demand) {
return NO_AGENT_MAPS;
}
const result: Record<string, Map<string, Agent> | undefined> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.agents;
@@ -64,6 +70,16 @@ export function useAggregatedAgents(options?: {
const result = useMemo(() => {
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
void runtimeVersion;
if (!demand) {
prevAgentsRef.current = new Map();
prevSortedRef.current = NO_AGENTS;
return {
agents: NO_AGENTS,
isLoading: false,
isInitialLoad: false,
isRevalidating: false,
};
}
const allAgents: AggregatedAgent[] = [];
const serverLabelById = new Map(
daemons.map((daemon) => [daemon.serverId, daemon.label] as const),
@@ -158,7 +174,7 @@ export function useAggregatedAgents(options?: {
isInitialLoad,
isRevalidating,
};
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
}, [daemons, demand, includeArchived, runtime, runtimeVersion, sessionAgents]);
return {
...result,
@@ -14,3 +14,21 @@ export function useDirectoryDemand(serverId: string | null | undefined): void {
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
}
/**
* Holds directory demand for several hosts at once, so a caller that needs the whole fleet
* (the favicon, the aggregated agent list) does not have to subscribe to every agent map to
* keep the directories loading.
*/
export function useAllDirectoryDemand(input: {
serverIds: readonly string[];
enabled: boolean;
}): void {
const { serverIds, enabled } = input;
useEffect(() => {
if (!enabled) return;
const runtime = getHostRuntimeStore();
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [enabled, serverIds]);
}
+22 -31
View File
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { useAllDirectoryDemand } from "./use-directory-demand";
import { getDesktopHost } from "@/desktop/host";
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
import { createMacDockBadgeCountSelector } from "@/utils/desktop-badge-state";
import { createFaviconStatusSelector, type FaviconStatus } from "@/utils/favicon-status";
import { isNative } from "@/constants/platform";
import { useSessionStore } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -24,21 +25,6 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
};
/* eslint-enable @typescript-eslint/no-require-imports */
function deriveFaviconStatus(
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): FaviconStatus {
const hasRunning = agents.some((agent) => agent.status === "running");
if (hasRunning) {
return "running";
}
const hasAttention = agents.some((agent) => agent.requiresAttention);
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
if (hasAttention || hasNeedsInput) {
return "attention";
}
return "none";
}
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) {
@@ -94,8 +80,15 @@ async function updateMacDockBadge(count?: number) {
}
export function useFaviconStatus() {
const { agents } = useAggregatedAgents({ demand: !isNative });
const workspaceStatuses = useWorkspaceStatusesForBadges();
const daemons = useHosts();
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
// The favicon reads the whole fleet, so it has to keep every host's directory loading even
// though it no longer subscribes to the agent maps.
useAllDirectoryDemand({ serverIds, enabled: !isNative });
const selectFaviconStatus = useMemo(createFaviconStatusSelector, []);
const selectMacDockBadgeCount = useMemo(createMacDockBadgeCountSelector, []);
const status = useSessionStore(selectFaviconStatus);
const dockBadgeCount = useSessionStore(selectMacDockBadgeCount);
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -112,17 +105,15 @@ export function useFaviconStatus() {
return () => mediaQuery.removeEventListener("change", handler);
}, []);
// Update favicon when agents or color scheme changes
useEffect(() => {
if (isNative) return;
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
}, [status, colorScheme]);
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme, workspaceStatuses]);
useEffect(() => {
if (isNative) return;
if (dockBadgeCount === lastDockBadgeCountRef.current) return;
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}, [dockBadgeCount]);
}
+2
View File
@@ -8,6 +8,8 @@ export {
subscribeOverlays,
} from "./internal/overlay-dispatch";
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
export { subscribeHardwareKeyboard } from "./internal/sources/hardware";
export type { HardwareKeyboardEvent } from "./internal/sources/hardware";
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
export { useHardwareKeyboardPresent } from "./availability";
@@ -4,20 +4,32 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
const surfaces = new Map<symbol, KeyboardFocusSurface>();
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
declare(surface: KeyboardFocusSurface) {
const token = Symbol(surface);
surfaces.set(token, surface);
notify();
return () => {
surfaces.delete(token);
if (surfaces.delete(token)) notify();
};
},
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
if (commandCenterOpen) return "command-center";
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
for (const surface of surfaces.values()) focused = surface;
return focused;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
@@ -10,6 +10,7 @@ export interface KeyboardSourceHandlers {
keyUp(event: KeyboardInputEvent): void;
blur(): void;
commandCenterOpen(): boolean;
subscribeCommandCenter(listener: () => void): () => void;
}
export function nativeKeyboardInput(
event: KeyboardInputEvent,
@@ -23,5 +24,6 @@ export interface KeyboardPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
usesDesktopBindings: boolean;
enabled: boolean;
}
@@ -366,7 +366,13 @@ describe("Electron guest source through shared resolution", () => {
},
captureFocusRestore: (id) => restored.push(id),
},
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
{
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
},
);
receive({
browserId: "tab-1",
@@ -1,5 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
@@ -19,6 +19,9 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
// so there is no character to take from it.
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -1,9 +1,10 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
addListener(
event: "onHardwareKeyboardShortcut",
handler: (event: HardwareKeyboardEvent) => void,
@@ -19,6 +20,9 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
module.setClaimedShortcuts(shortcuts);
}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -3,12 +3,19 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
isComposing: boolean;
phase: "down" | "up";
}
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
export interface ClaimedShortcut {
code: string;
shift: boolean;
alt: boolean;
}
export function subscribeHardwareKeyboard(
_handler: (event: HardwareKeyboardEvent) => void,
): () => void {
return () => {};
}
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return false;
@@ -1,11 +1,19 @@
import type { KeyboardSourceHandlers } from "./contract";
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
import { attachHardwareKeyboardSource } from "./native";
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
import {
setHardwareKeyboardClaimedShortcuts,
setHardwareKeyboardOverlayKeys,
subscribeHardwareKeyboard,
} from "./hardware";
import { createHardwareKeyboardSource } from "./native";
const hardwareKeyboard = createHardwareKeyboardSource({
subscribe: subscribeHardwareKeyboard,
setOverlayKeys: setHardwareKeyboardOverlayKeys,
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
});
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
return attachHardwareKeyboardSource(
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
handlers,
);
return hardwareKeyboard.attach(handlers);
}
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
hardwareKeyboard.publishPolicy(input);
}
@@ -1,4 +1,3 @@
import { usesDesktopBindings } from "../conventions";
import { getDesktopHost } from "@/desktop/host";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -26,7 +25,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
}
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
const policy = input.enabled
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
? buildBrowserKeyboardPolicy(input)
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}
@@ -1,31 +1,92 @@
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
import { focusSurfaces } from "../focus-surfaces";
import { overlays } from "../overlays";
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
import type { HardwareKeyboardEvent } from "./hardware";
import {
nativeKeyboardInput,
type KeyboardPolicyInput,
type KeyboardSourceHandlers,
} from "./contract";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeySource {
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
}
export function attachHardwareKeyboardSource(
source: HardwareKeySource,
handlers: KeyboardSourceHandlers,
): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateKeys();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
/**
* The shortcuts a focused text control would otherwise type or act on.
*
* The native window observes every press but cannot wait for the pipeline to
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
* combos do not type, and the platform's own editing combos stay with the text
* control through `editable: false`. Unmodified keys belong to overlays and the
* composer's Return.
*/
function claimedShortcuts(
policy: KeyboardPolicyInput | null,
commandCenterOpen: boolean,
): ClaimedShortcut[] {
if (!policy?.enabled) return [];
const context = {
isMac: policy.isMac,
usesDesktopBindings: policy.usesDesktopBindings,
commandCenterOpen,
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
};
const claimed = new Map<string, ClaimedShortcut>();
for (const binding of policy.bindings) {
const combo = binding.parsedChord[0];
if (!combo || binding.preventDefault === false) continue;
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
const shift = combo.shift === true;
const alt = combo.alt === true;
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeOverlays();
source.setOverlayKeys([]);
}
return [...claimed.values()];
}
export function createHardwareKeyboardSource(source: HardwareKeySource) {
let policy: KeyboardPolicyInput | null = null;
let updateClaims = () => {};
return {
publishPolicy(next: KeyboardPolicyInput) {
policy = next;
updateClaims();
},
attach(handlers: KeyboardSourceHandlers): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateClaims = () =>
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
updateKeys();
updateClaims();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeFocus();
unsubscribeCommandCenter();
unsubscribeOverlays();
updateClaims = () => {};
source.setOverlayKeys([]);
source.setClaimedShortcuts([]);
};
},
};
}
@@ -226,6 +226,20 @@ describe("keyboard-shortcuts", () => {
action: "workspace.tab.navigate.relative",
payload: { delta: 1 },
},
{
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+[",
event: { key: "{", code: "BracketLeft", metaKey: true, shiftKey: true },
context: { isMac: true, usesDesktopBindings: true },
action: "workspace.tab.navigate.relative",
payload: { delta: -1 },
},
{
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+]",
event: { key: "}", code: "BracketRight", metaKey: true, shiftKey: true },
context: { isMac: true, usesDesktopBindings: true },
action: "workspace.tab.navigate.relative",
payload: { delta: 1 },
},
{
name: "matches Mod+T to open new tab",
event: { key: "t", code: "KeyT", metaKey: true },
@@ -479,6 +493,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, usesDesktopBindings: true, focusScope: "terminal" },
},
{
name: "does not switch tabs with Alt+Shift+] on mac desktop (Cmd+Shift+] only)",
event: { key: "’", code: "BracketRight", altKey: true, shiftKey: true },
context: { isMac: true, usesDesktopBindings: true },
},
{
name: "does not match Ctrl+T on mac (Cmd only)",
event: { key: "t", code: "KeyT", ctrlKey: true },
@@ -1306,6 +1325,17 @@ describe("unassigned shortcuts", () => {
expect(getBindingIdForAction("workspace-tab-new", desktopNonMac)).toBe(TAB_NEW_BINDING);
});
it("advertises Cmd+Shift+brackets for tab navigation on macOS desktop", () => {
const desktopMac = { isMac: true, usesDesktopBindings: true };
expect(getDefaultKeysForAction("workspace-tab-prev", desktopMac)).toEqual([
["mod", "shift", "["],
]);
expect(getDefaultKeysForAction("workspace-tab-next", desktopMac)).toEqual([
["mod", "shift", "]"],
]);
});
it("still lists the default keys when nothing is overridden", () => {
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
@@ -715,11 +715,37 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Tab relative navigation ---
// macOS desktop uses Cmd+Shift+brackets; the browser owns those keys, so
// macOS web keeps Alt+Shift alongside every non-mac platform.
{
id: "workspace-tab-navigate-relative-cmd-shift-left-mac",
action: "workspace.tab.navigate.relative",
combo: "Cmd+Shift+[",
when: { mac: true, desktopBindings: true, commandCenter: false },
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "tabs-panes",
label: "Previous tab",
},
},
{
id: "workspace-tab-navigate-relative-cmd-shift-right-mac",
action: "workspace.tab.navigate.relative",
combo: "Cmd+Shift+]",
when: { mac: true, desktopBindings: true, commandCenter: false },
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "tabs-panes",
label: "Next tab",
},
},
{
id: "workspace-tab-navigate-relative-alt-shift-left",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+[",
when: { commandCenter: false },
when: { mac: false, commandCenter: false },
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
@@ -731,7 +757,31 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-navigate-relative-alt-shift-right",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+]",
when: { commandCenter: false },
when: { mac: false, commandCenter: false },
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "tabs-panes",
label: "Next tab",
},
},
{
id: "workspace-tab-navigate-relative-alt-shift-left-mac-web",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+[",
when: { mac: true, desktopBindings: false, commandCenter: false },
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "tabs-panes",
label: "Previous tab",
},
},
{
id: "workspace-tab-navigate-relative-alt-shift-right-mac-web",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+]",
when: { mac: true, desktopBindings: false, commandCenter: false },
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
@@ -25,6 +25,7 @@ describe("DOM source adapter", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
it.each([true, false])(
"delivers hardware down/up and declared focus with mac=%s",
async (isMac) => {
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
const actions: string[] = [];
const releases: string[] = [];
@@ -148,23 +148,22 @@ describe("hardware source adapter", () => {
},
overlays: createOverlayRegistry(),
});
const detach = attachHardwareKeyboardSource(
{
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
setOverlayKeys() {},
const detach = createHardwareKeyboardSource({
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
{
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
},
);
setOverlayKeys() {},
setClaimedShortcuts() {},
}).attach({
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const down = {
...keyboardEvent("k", "KeyK", isMac),
isComposing: false,
@@ -180,4 +179,94 @@ describe("hardware source adapter", () => {
expect(events.size).toBe(0);
},
);
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
let claimed: string[] = [];
const source = createHardwareKeyboardSource({
subscribe: () => () => {},
setOverlayKeys() {},
setClaimedShortcuts(shortcuts) {
claimed = shortcuts
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
.map((parts) => parts.filter(Boolean).join("+"))
.sort();
},
});
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
let commandCenterOpen = false;
const commandCenterListeners = new Set<() => void>();
const detach = source.attach({
keyDown() {},
keyUp() {},
blur() {},
commandCenterOpen: () => commandCenterOpen,
subscribeCommandCenter(listener) {
commandCenterListeners.add(listener);
return () => commandCenterListeners.delete(listener);
},
});
const setCommandCenterOpen = (open: boolean) => {
commandCenterOpen = open;
for (const listener of commandCenterListeners) listener();
};
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
expect(claimed).toEqual([]);
const releaseComposer = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Shift+Tab"]);
setCommandCenterOpen(true);
expect(claimed).toEqual([]);
setCommandCenterOpen(false);
expect(claimed).toEqual(["Shift+Tab"]);
releaseComposer();
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseRebound = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Alt+KeyM"]);
releaseRebound();
// A chord's later step is only a shortcut while the chord is in progress;
// claiming it always would stop the composer typing a capital A.
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseChord = focusSurfaces.declare("message-input");
expect(claimed).toEqual([]);
releaseChord();
const releaseFinal = focusSurfaces.declare("message-input");
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: false,
});
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
expect(claimed).toEqual(["Shift+Tab"]);
detach();
expect(claimed).toEqual([]);
releaseFinal();
});
});
+5 -1
View File
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
},
[bindings, enabled, isMac],
);
@@ -265,6 +265,10 @@ export function useKeyboardShortcuts({
keyUp: handleKeyUp,
blur: resetModifiers,
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
subscribeCommandCenter: (listener) =>
useKeyboardShortcutsStore.subscribe((state, previous) => {
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
}),
});
return () => {
detach();
@@ -134,6 +134,7 @@ describe("DOM source through the shared resolver", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -1,8 +1,5 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -126,7 +123,6 @@ import {
} from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
import { retriedAgentRequest } from "./new-workspace/retry-agent-request";
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
@@ -243,7 +239,6 @@ interface SubmitDraftInput {
clearConsumedDraft: () => void;
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
draftKey: string;
@@ -471,13 +466,13 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
images: images?.length ? images : undefined,
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
const execute = async (): Promise<AgentSnapshotPayload> => {
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
agent: initialAgent,
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
@@ -516,11 +511,8 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute(retriedAgentRequest(initialAgent, request, cwd)),
};
// A retry after a failed agent start belongs to the ready workspace's draft, not to this creation.
const agentCreation = { result: Promise.resolve().then(() => execute()) };
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request";
const initial: WorkspaceAgentRequest = {
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
metadata: { "issues.key": "PAS-1" },
initialPrompt: "first try",
clientMessageId: "draft_1:initial-message",
};
describe("retriedAgentRequest", () => {
it("creates with the metadata the draft resubmitted", () => {
const retried = retriedAgentRequest(
initial,
{
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
initialPrompt: "second try",
metadata: { "issues.key": "PAS-2" },
},
"/repo",
);
expect(retried.metadata).toEqual({ "issues.key": "PAS-2" });
expect(retried.initialPrompt).toBe("second try");
expect(retried.clientMessageId).toBe("draft_1:initial-message");
});
it("creates with no metadata once the draft cleared it", () => {
const retried = retriedAgentRequest(
initial,
{ config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" },
"/repo",
);
expect("metadata" in retried).toBe(false);
});
it("keeps the first attempt's directory over the resubmitted config", () => {
const retried = retriedAgentRequest(
initial,
{
config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" },
initialPrompt: "second try",
},
"/repo",
);
expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" });
});
});
@@ -1,28 +0,0 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
export type WorkspaceAgentRequest = NonNullable<CreateWorkspaceRequestOptions["agent"]>;
/**
* The agent a retried creation asks for: everything the draft resubmitted (config, prompt,
* images, attachments and metadata, cleared included) on the first attempt's identity, so a
* retry creates what the composer shows now, not what it showed when creation first failed.
*/
export function retriedAgentRequest(
initial: WorkspaceAgentRequest,
request: CreateAgentRequestOptions,
cwd: string,
): WorkspaceAgentRequest {
const { metadata: _replaced, ...identity } = initial;
return {
...identity,
config: { ...request.config!, cwd },
initialPrompt: request.initialPrompt ?? "",
clientMessageId: initial.clientMessageId,
images: request.images,
attachments: request.attachments,
...(request.metadata ? { metadata: request.metadata } : {}),
};
}
@@ -17,22 +17,16 @@ import {
selectWorkspaceExists,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceStatusesForBadges,
workspaceEqualityFns,
type WorkspaceStructure,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
// These are the ONLY supported ways to read workspaces from the session store.
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
// or the sessions dict — it breaks re-render isolation.
export type {
DesktopBadgeWorkspaceStatus,
WorkspaceStructure,
WorkspaceStructureProject,
} from "./selectors";
export type { WorkspaceStructure, WorkspaceStructureProject } from "./selectors";
export function useWorkspace(
serverId: string | null,
@@ -163,11 +157,3 @@ export function useHasWorkspaces(serverId: string | null): boolean {
workspaceEqualityFns.identity,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
}
@@ -14,7 +14,6 @@ import {
selectWorkspaceDirectory,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
type SidebarOrderSnapshot,
@@ -589,36 +588,6 @@ describe("selectHasWorkspaces", () => {
});
});
describe("selectWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
const before = tracked.current;
expect(before).toEqual(["done", "attention"]);
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
expect(tracked.current).not.toBe(before);
expect(tracked.current).toEqual(["failed", "attention"]);
tracked.stop();
});
});
it("a prompt changes only its project's structure and output preserves both project identities", () => {
const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") };
const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") };
@@ -5,11 +5,9 @@ import {
type WorkspaceStructure,
type WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface SessionsSnapshot {
@@ -321,15 +319,3 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string |
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
}
export function selectWorkspaceStatusesForBadges(
state: SessionsSnapshot,
): DesktopBadgeWorkspaceStatus[] {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
}
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
});
test("consuming draft presentation retains its running creation through remount and retry", () => {
const store = loadProductionStore();
const result = new Promise<never>(() => {});
const creation = { result, retry: () => result };
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-one",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
agentCreation: creation,
};
store.getState().setPending(submission);
store.getState().consumePending(submission);
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
store.getState().clearDraftSetup({ draftId: submission.draftId });
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
});
@@ -1,4 +1,3 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
@@ -9,7 +8,6 @@ export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
workspaceId: string;
@@ -35,7 +33,6 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
@@ -69,12 +66,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({
pendingByDraftId: {},
setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) =>
set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
@@ -94,10 +87,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId, creationByDraftId };
return { setupByDraftId };
});
},
consumePending: (input) => {
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
import { shallow } from "zustand/shallow";
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
const label = "paseo.open-agent-tab.client-1";
@@ -51,3 +52,54 @@ describe("getAgentTabsNeedingOpenLabel", () => {
).toEqual([]);
});
});
describe("selectOpenTabAgents", () => {
function stateWith(agents: Agent[]) {
return {
sessions: {
srv: { agents: new Map(agents.map((entry) => [entry.id, entry])), agentDetails: new Map() },
},
};
}
it("stays equal when only an agent without an open tab changes", () => {
const open = agent({ id: "open" });
const before = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
serverId: "srv",
agentIds: ["open"],
});
const after = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
serverId: "srv",
agentIds: ["open"],
});
expect(shallow(before, after)).toBe(true);
});
it("changes when an agent with an open tab changes", () => {
const elsewhere = agent({ id: "elsewhere" });
const before = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
serverId: "srv",
agentIds: ["open"],
});
const after = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
serverId: "srv",
agentIds: ["open"],
});
expect(shallow(before, after)).toBe(false);
});
it("falls back to agent details and returns null for unknown agents", () => {
const detail = agent({ id: "detail" });
const state = {
sessions: {
srv: { agents: new Map<string, Agent>(), agentDetails: new Map([["detail", detail]]) },
},
};
expect(
selectOpenTabAgents(state, { serverId: "srv", agentIds: ["detail", "missing"] }),
).toEqual([detail, null]);
});
});
@@ -23,3 +23,24 @@ export function getAgentTabsNeedingOpenLabel(input: {
}
return [...agentIds];
}
interface OpenTabAgentSource {
sessions: Record<
string,
{ agents: ReadonlyMap<string, Agent>; agentDetails: ReadonlyMap<string, Agent> } | undefined
>;
}
/**
* The agents behind the given open tabs, in tab order. Compared element by element, this changes
* only when one of those agents changes, not on updates to other agents on the host.
*/
export function selectOpenTabAgents(
state: OpenTabAgentSource,
input: { serverId: string; agentIds: readonly string[] },
): (Agent | null)[] {
const session = state.sessions[input.serverId];
return input.agentIds.map(
(agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null,
);
}
@@ -1,10 +1,11 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import { getOrCreateClientId } from "@/utils/client-id";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
function increment(value: number): number {
return value + 1;
@@ -16,9 +17,16 @@ export function useOpenAgentTabLabels(input: {
tabs: WorkspaceTab[];
enabled: boolean;
}): void {
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
const agentDetails = useSessionStore(
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
const openAgentIds = useMemo(
() => input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
[input.tabs],
);
// Only the agents with open tabs can need a label. Subscribing to the whole agent map would
// re-render the workspace screen on every update to any agent on the host.
const openAgents = useSessionStore(
useShallow((state) =>
selectOpenTabAgents(state, { serverId: input.serverId, agentIds: openAgentIds }),
),
);
const pendingAgentIdsRef = useRef(new Set<string>());
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -39,15 +47,13 @@ export function useOpenAgentTabLabels(input: {
return;
}
const openAgentIds = new Set(
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
);
const agentsById = new Map(openAgentIds.map((agentId, index) => [agentId, openAgents[index]]));
for (const agentId of pendingAgentIdsRef.current) {
if (!openAgentIds.has(agentId)) {
if (!agentsById.has(agentId)) {
pendingAgentIdsRef.current.delete(agentId);
}
}
if (openAgentIds.size === 0) {
if (agentsById.size === 0) {
return;
}
@@ -57,7 +63,7 @@ export function useOpenAgentTabLabels(input: {
const label = getOpenAgentTabLabel(clientId);
const agentIds = getAgentTabsNeedingOpenLabel({
tabs: input.tabs,
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
getAgent: (agentId) => agentsById.get(agentId),
label,
pendingAgentIds: pendingAgentIdsRef.current,
});
@@ -82,5 +88,5 @@ export function useOpenAgentTabLabels(input: {
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
}
})();
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
}, [input.client, input.enabled, input.tabs, openAgentIds, openAgents, retryVersion]);
}
@@ -6,7 +6,7 @@ import {
TERMINAL_INPUT_CONTEXT_MENU_HIDDEN,
TERMINAL_INPUT_HITBOX_SIZE,
} from "./terminal-input.native";
import { forwardNativeTerminalKey, type NativeTerminalKeyEvent } from "./terminal-key-events";
import { createNativeTerminalKeyEvent, resolveHardwareTerminalKey } from "./terminal-key-events";
describe("native terminal typed input", () => {
function inputData(change: { data: string; shouldClear: boolean }): string {
@@ -149,25 +149,18 @@ describe("native terminal typed input", () => {
});
});
it("forwards native arrow key events to the terminal key path", () => {
const events: NativeTerminalKeyEvent[] = [];
forwardNativeTerminalKey({
key: "ArrowUp",
onTerminalKey: (event) => {
events.push(event);
},
it("identifies hardware arrows by code and keeps their modifiers", () => {
// UIKit leaves `key` as a sentinel for Left/Right, so the code leads.
expect(resolveHardwareTerminalKey({ key: "\uF702", code: "ArrowLeft" })).toBe("ArrowLeft");
expect(resolveHardwareTerminalKey({ key: "ArrowUp", code: "" })).toBe("ArrowUp");
expect(resolveHardwareTerminalKey({ key: "a", code: "KeyA" })).toBeNull();
expect(createNativeTerminalKeyEvent("ArrowDown", { shift: true, alt: true })).toEqual({
key: "ArrowDown",
ctrl: false,
shift: true,
alt: true,
meta: false,
});
expect(events).toEqual([
{
key: "ArrowUp",
ctrl: false,
shift: false,
alt: false,
meta: false,
},
]);
});
it("keeps the hidden input from owning terminal long-press selection", () => {
@@ -1,5 +1,6 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import {
Platform,
StyleSheet,
type NativeSyntheticEvent,
type StyleProp,
@@ -10,10 +11,19 @@ import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events";
import { hasActiveOverlay, subscribeHardwareKeyboard } from "@/keyboard";
import {
createNativeTerminalKeyEvent,
resolveHardwareTerminalKey,
resolveNativeTerminalKey,
type NativeTerminalKey,
type NativeTerminalKeyEvent,
} from "./terminal-key-events";
export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true;
export const TERMINAL_INPUT_HITBOX_SIZE = 1;
/** Matched by the Android hardware keyboard module to claim arrow keys for this input. */
export const TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input";
export interface TerminalTextInputChange {
data: string;
@@ -39,7 +49,7 @@ interface TerminalInputProps {
isKeyboardVisible: boolean;
onFocus?: () => void;
onInput?: (data: string) => void;
onTerminalKey?: (key: NativeTerminalKey) => void;
onTerminalKey?: (event: NativeTerminalKeyEvent) => void;
style?: StyleProp<TextStyle>;
}
@@ -187,6 +197,8 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
const inputRef = useRef<EditingTextInputHandle>(null);
const isFocusedRef = useRef(false);
const pendingFocusFrameRef = useRef<number | null>(null);
const onTerminalKeyRef = useRef(onTerminalKey);
onTerminalKeyRef.current = onTerminalKey;
const inputState = useMemo(() => createTerminalTextInputState(), []);
const inputStyle = useMemo(() => [styles.input, style], [style]);
@@ -250,6 +262,27 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
useEffect(() => clearPendingFocus, [clearPendingFocus]);
useEffect(() => {
// Physical arrows arrive through the native key stream, never through
// TextInput.onKeyPress (RN builds that event from the IME connection).
// The native module claims them for this input so the hidden buffer
// cannot move its caret, then re-emits them here for the PTY.
if (Platform.OS !== "android") return;
return subscribeHardwareKeyboard((event) => {
if (!isFocusedRef.current || event.phase !== "down" || event.isComposing) return;
if (hasActiveOverlay()) return;
const key = resolveHardwareTerminalKey(event);
if (!key) return;
onTerminalKeyRef.current?.({
key,
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
meta: event.metaKey,
});
});
}, []);
useImperativeHandle(
ref,
() => ({
@@ -293,7 +326,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const change = inputState.receiveKeyPress(event.nativeEvent.key);
if (change.key) {
onTerminalKey?.(change.key);
onTerminalKey?.(createNativeTerminalKeyEvent(change.key));
}
if (change.data.length > 0) {
onInput?.(change.data);
@@ -321,6 +354,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
blurOnSubmit={false}
importantForAutofill="no"
multiline={true}
nativeID={TERMINAL_INPUT_NATIVE_ID}
onChangeText={handleChangeText}
onBlur={handleBlur}
onFocus={handleFocus}
@@ -22,19 +22,26 @@ export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null
}
}
export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent {
return {
key,
ctrl: false,
shift: false,
alt: false,
meta: false,
};
/**
* Physical keyboards report the DOM `code`. UIKit leaves `key` as a sentinel
* for Left/Right, so the code is the only reliable identifier there.
*/
export function resolveHardwareTerminalKey(input: {
key?: string;
code?: string;
}): NativeTerminalKey | null {
return resolveNativeTerminalKey(input.code ?? "") ?? resolveNativeTerminalKey(input.key ?? "");
}
export function forwardNativeTerminalKey(input: {
key: NativeTerminalKey;
onTerminalKey?: NativeTerminalKeyHandler;
}): void {
input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key));
export function createNativeTerminalKeyEvent(
key: NativeTerminalKey,
modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {},
): NativeTerminalKeyEvent {
return {
key,
ctrl: modifiers.ctrl ?? false,
shift: modifiers.shift ?? false,
alt: modifiers.alt ?? false,
meta: modifiers.meta ?? false,
};
}
@@ -115,6 +115,26 @@ interface CreateTerminalHostInput {
isMac?: boolean;
}
/** Text typed into xterm's textarea, as the browser reports a typed character. */
function typeText(mounted: MountedTerminal, text: string): void {
mounted.host
.querySelector(".xterm-helper-textarea")
?.dispatchEvent(
new InputEvent("input", { data: text, inputType: "insertText", bubbles: true }),
);
}
/** A native paste of plain text into xterm's textarea. */
function pasteText(mounted: MountedTerminal, text: string): void {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
mounted.host
.querySelector(".xterm-helper-textarea")
?.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
}
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
const root = document.createElement("div");
root.style.width = `${input.width}px`;
@@ -824,6 +844,23 @@ describe("incremental replay lifecycle", () => {
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
it("keeps what the user types, but not pastes, while a replay is still parsing", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07restored") });
mounted.runtime.replay({ type: "end" });
typeText(mounted, "a");
pasteText(mounted, "echo one\necho two\n");
typeText(mounted, "f");
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual(["a", "f"]);
});
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const text = "A😀B❤️C👋🏽D👨‍👩‍👧E";
@@ -124,6 +124,7 @@ interface TerminalEmulatorRuntimeDisposables {
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeFilePasteListener: () => void;
removeUserInputListeners: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
@@ -157,6 +158,10 @@ const isAppleHandheld =
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
// DOM events xterm turns into typed data on its textarea. Pastes stay suppressed during a
// replay: until the snapshot restores bracketed paste mode, a multi-line paste would run
// line by line in the shell.
const USER_INPUT_EVENT_TYPES = ["keydown", "keypress", "input"] as const;
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
const terminalOutputEncoder = new TextEncoder();
@@ -313,6 +318,8 @@ export class TerminalEmulatorRuntime {
private hasUngatedWrites = false;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
// The latest user input event the host saw; its eventPhase is NONE once dispatch ends.
private userInputEvent: Event | null = null;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private themeBackgroundElements: HTMLElement[] = [];
@@ -739,8 +746,17 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
// xterm reports what the user types and the replies it generates while parsing
// through the same onData. Replies only come out of parsing a write, never while a
// user input event is dispatching, so typing survives a replay that suppresses them.
const recordUserInputEvent = (event: Event): void => {
this.userInputEvent = event;
};
for (const type of USER_INPUT_EVENT_TYPES) {
input.host.addEventListener(type, recordUserInputEvent, true);
}
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
if (this.suppressInput && !this.isDispatchingUserInput()) {
return;
}
this.callbacks.onInput?.(data);
@@ -844,6 +860,12 @@ export class TerminalEmulatorRuntime {
removeFilePasteListener: () => {
input.host.removeEventListener("paste", filePasteHandler, true);
},
removeUserInputListeners: () => {
for (const type of USER_INPUT_EVENT_TYPES) {
input.host.removeEventListener(type, recordUserInputEvent, true);
}
this.userInputEvent = null;
},
restoreDocumentStyles,
restoreViewportStyles,
disposeFitAddon: () => {
@@ -873,6 +895,7 @@ export class TerminalEmulatorRuntime {
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeFilePasteListener();
disposables.removeUserInputListeners();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
disposables.disposeTerminal();
@@ -913,6 +936,10 @@ export class TerminalEmulatorRuntime {
this.terminal?.paste(text);
}
private isDispatchingUserInput(): boolean {
return (this.userInputEvent?.eventPhase ?? Event.NONE) !== Event.NONE;
}
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
const text = this.clipboard.readText().catch(() => "");
const onFilePaste = this.callbacks.onFilePaste;
@@ -1,9 +1,39 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
createMacDockBadgeCountSelector,
isWorkspaceActionableForDesktopBadge,
selectMacDockBadgeCount,
} from "./desktop-badge-state";
function workspace(id: string, status: WorkspaceDescriptor["status"]): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status,
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
}
function stateWith(...workspaces: WorkspaceDescriptor[]) {
return {
sessions: {
srv_a: { workspaces: new Map(workspaces.map((entry) => [entry.id, entry])) },
},
};
}
describe("desktop-badge-state", () => {
it("treats attention-requiring workspace statuses as actionable", () => {
expect(isWorkspaceActionableForDesktopBadge("attention")).toBe(true);
@@ -17,18 +47,40 @@ describe("desktop-badge-state", () => {
});
it("returns undefined when no visible workspaces need attention", () => {
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
expect(
selectMacDockBadgeCount(stateWith(workspace("a", "done"), workspace("b", "running"))),
).toBeUndefined();
});
it("counts only actionable visible workspaces", () => {
expect(
deriveMacDockBadgeCountFromWorkspaceStatuses([
"done",
"attention",
"running",
"needs_input",
"failed",
]),
).toBe(3);
it("counts only actionable visible workspaces across every host", () => {
const state = {
sessions: {
srv_a: {
workspaces: new Map([
["a", workspace("a", "done")],
["b", workspace("b", "attention")],
]),
},
srv_b: {
workspaces: new Map([
["c", workspace("c", "running")],
["d", workspace("d", "needs_input")],
["e", workspace("e", "failed")],
]),
},
},
};
expect(selectMacDockBadgeCount(state)).toBe(3);
});
it("recounts only when a workspace map is replaced", () => {
const select = createMacDockBadgeCountSelector();
const state = stateWith(workspace("a", "attention"));
expect(select(state)).toBe(1);
state.sessions.srv_a.workspaces.set("b", workspace("b", "failed"));
expect(select({ sessions: { ...state.sessions } })).toBe(1);
expect(select(stateWith(workspace("a", "attention"), workspace("b", "failed")))).toBe(2);
});
});
+31 -4
View File
@@ -1,14 +1,41 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
interface WorkspaceStatusSession {
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
}
interface WorkspaceStatusSource {
sessions: Record<string, WorkspaceStatusSession | undefined>;
}
export function isWorkspaceActionableForDesktopBadge(status: DesktopBadgeWorkspaceStatus): boolean {
return status === "attention" || status === "needs_input" || status === "failed";
}
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
statuses: readonly DesktopBadgeWorkspaceStatus[],
): number | undefined {
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
/**
* The macOS dock badge only needs a count, so count here instead of materialising every
* workspace status and comparing the whole list on every store write.
*/
export function selectMacDockBadgeCount(state: WorkspaceStatusSource): number | undefined {
let actionableCount = 0;
for (const session of Object.values(state.sessions)) {
if (!session) continue;
for (const workspace of session.workspaces.values()) {
if (isWorkspaceActionableForDesktopBadge(workspace.status)) actionableCount += 1;
}
}
return actionableCount > 0 ? actionableCount : undefined;
}
/** A session-store selector for the dock badge count that recounts only when a workspace map changes. */
export function createMacDockBadgeCountSelector(): (
state: WorkspaceStatusSource,
) => number | undefined {
return memoizeOnSessionMaps<WorkspaceStatusSession, WorkspaceStatusSource, number | undefined>(
(session) => session.workspaces,
selectMacDockBadgeCount,
);
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { createFaviconStatusSelector, deriveFaviconStatus } from "./favicon-status";
function agent(input: Partial<Agent> & { id: string }): Agent {
return {
status: "idle",
background: false,
archivedAt: null,
requiresAttention: false,
pendingPermissions: [],
...input,
} as Agent;
}
function stateWith(...agents: Agent[]) {
return { sessions: { srv: { agents: new Map(agents.map((entry) => [entry.id, entry])) } } };
}
describe("deriveFaviconStatus", () => {
it("is running when any visible agent runs", () => {
expect(
deriveFaviconStatus(
stateWith(
agent({ id: "a", requiresAttention: true }),
agent({ id: "b", status: "running" }),
),
),
).toBe("running");
});
it("needs attention for attention or pending permissions", () => {
expect(deriveFaviconStatus(stateWith(agent({ id: "a", requiresAttention: true })))).toBe(
"attention",
);
expect(
deriveFaviconStatus(
stateWith(agent({ id: "a", pendingPermissions: [{}] as Agent["pendingPermissions"] })),
),
).toBe("attention");
});
it("ignores background and archived agents", () => {
expect(
deriveFaviconStatus(
stateWith(
agent({ id: "a", status: "running", background: true }),
agent({ id: "b", requiresAttention: true, archivedAt: new Date() }),
),
),
).toBe("none");
});
});
describe("createFaviconStatusSelector", () => {
it("reuses its result while the agent maps are unchanged", () => {
const select = createFaviconStatusSelector();
const state = stateWith(agent({ id: "a", status: "running" }));
expect(select(state)).toBe("running");
// Same agent map, mutated behind the store's back: the selector must not rescan.
state.sessions.srv.agents.set("a", agent({ id: "a" }));
expect(select({ sessions: { ...state.sessions } })).toBe("running");
});
it("rescans when an agent map is replaced", () => {
const select = createFaviconStatusSelector();
expect(select(stateWith(agent({ id: "a", status: "running" })))).toBe("running");
expect(select(stateWith(agent({ id: "a" })))).toBe("none");
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { Agent } from "@/stores/session-store";
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
export type FaviconStatus = "none" | "running" | "attention";
interface FaviconAgentSession {
agents: ReadonlyMap<string, Agent>;
}
interface FaviconAgentSource {
sessions: Record<string, FaviconAgentSession | undefined>;
}
/**
* The favicon is the only always-mounted consumer of the whole agent directory, so derive its
* three states here instead of building the aggregated agent list: the list allocates a
* projection per agent, deep-compares it and sorts every agent on the host on each update.
*/
export function deriveFaviconStatus(state: FaviconAgentSource): FaviconStatus {
let hasAttention = false;
let hasNeedsInput = false;
for (const session of Object.values(state.sessions)) {
if (!session) continue;
for (const agent of session.agents.values()) {
if (agent.background || agent.archivedAt) continue;
if (agent.status === "running") return "running";
if (agent.requiresAttention) hasAttention = true;
if (agent.pendingPermissions.length > 0) hasNeedsInput = true;
}
}
return hasAttention || hasNeedsInput ? "attention" : "none";
}
/** A session-store selector for the favicon status that rescans only when an agent map changes. */
export function createFaviconStatusSelector(): (state: FaviconAgentSource) => FaviconStatus {
return memoizeOnSessionMaps<FaviconAgentSession, FaviconAgentSource, FaviconStatus>(
(session) => session.agents,
deriveFaviconStatus,
);
}
@@ -0,0 +1,31 @@
interface SessionsSource<TSession> {
sessions: Record<string, TSession | undefined>;
}
/**
* Wraps a whole-store selector so it reruns only when one of the per-session maps it reads has
* changed. Session-store writes that leave those maps alone (timeline updates, drafts) then cost
* a reference check per host instead of a walk over every entry.
*/
export function memoizeOnSessionMaps<TSession, TState extends SessionsSource<TSession>, TResult>(
pickMap: (session: TSession) => unknown,
derive: (state: TState) => TResult,
): (state: TState) => TResult {
let previousMaps: unknown[] | null = null;
let previousResult: TResult;
return (state) => {
const maps = Object.values(state.sessions).map((session) =>
session ? pickMap(session) : undefined,
);
if (
previousMaps &&
previousMaps.length === maps.length &&
previousMaps.every((map, index) => map === maps[index])
) {
return previousResult;
}
previousMaps = maps;
previousResult = derive(state);
return previousResult;
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -49,8 +49,8 @@
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
},
"dependencies": {
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+5
View File
@@ -306,6 +306,11 @@ export interface PaseoAgentTimelineRefetchOptions {
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
/**
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
*/
wholeTurns?: boolean;
requestId?: string;
}
@@ -0,0 +1,68 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../../app/e2e/support/fixtures";
import { clickNewTerminal, gotoWorkspace } from "../../app/e2e/support/helpers/launcher";
import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs";
// macOS desktop switches tabs with Cmd+Shift+brackets, like other Mac apps.
// Web keeps Alt+Shift (the browser owns Cmd+Shift+brackets) and is covered in
// the app browser suite. Windows/Linux desktop can't be emulated here: shortcut
// OS detection falls back to the host user agent for non-darwin bridges.
async function installMacDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
window.paseoDesktop = {
platform: "darwin",
events: { on: () => () => {} },
invoke: async (command: string) => {
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
};
});
}
const terminalTabs = (page: Page) =>
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true });
async function openTwoTerminalTabs(page: Page) {
await waitForWorkspaceTabsVisible(page);
await clickNewTerminal(page);
await expect(terminalTabs(page)).toHaveCount(1, { timeout: 30_000 });
await clickNewTerminal(page);
await expect(terminalTabs(page)).toHaveCount(2, { timeout: 30_000 });
const first = terminalTabs(page).nth(0);
const second = terminalTabs(page).nth(1);
await expect(second).toHaveAttribute("aria-selected", "true");
return { first, second };
}
test.describe("Previous/next tab shortcuts on desktop", () => {
test("macOS desktop switches tabs with Cmd+Shift+[ and ], not Alt+Shift", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-mac-desktop-" });
try {
await installMacDesktopBridge(page);
await gotoWorkspace(page, workspace.workspaceId);
const { first, second } = await openTwoTerminalTabs(page);
await page.keyboard.press("Meta+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Meta+Shift+BracketRight");
await expect(second).toHaveAttribute("aria-selected", "true");
// Alt+Shift is the web and Windows/Linux binding and must not fire here.
// Had it moved to the first tab, Cmd+Shift+[ would wrap back to the second.
await page.keyboard.press("Alt+Shift+BracketLeft");
await page.keyboard.press("Meta+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+11 -1
View File
@@ -393,6 +393,7 @@ describe("lifecycle hook SDK types", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
@@ -426,8 +427,17 @@ describe("lifecycle hook SDK types", () => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
const { agent, outcome } = event;
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
event.timeline;
const turn = await paseo.agents
.ref(agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items: readonly AgentTimelineItem[] = turn.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item);
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
@@ -1527,7 +1527,9 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
name: snapshot.name ?? snapshot.title,
detail,
};
if (status === "failed") return { ...base, status, error: snapshot.output };
if (status === "failed") {
return { ...base, status, error: snapshot.output ?? { message: "Tool failed" } };
}
return { ...base, status, error: null };
}
+67
View File
@@ -9,6 +9,7 @@ import {
} from "@agentclientprotocol/sdk";
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
import { afterEach, describe, expect, it } from "vitest";
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
const temporaryDirectories: string[] = [];
@@ -890,6 +891,72 @@ lines.on("line", (line) => {
},
);
it.each([
{ status: "failed", output: undefined, error: { message: "Tool failed" } },
{ status: "failed", output: null, error: { message: "Tool failed" } },
{
status: "failed",
output: { message: "File not found" },
error: { message: "File not found" },
},
{ status: "failed", output: "Permission denied", error: "Permission denied" },
{ status: "completed", output: null, error: null },
] as const)(
"keeps a $status ACP tool with output $output valid on the history wire",
async ({ status, output, error }) => {
const harness = connectorHarness({
promptUpdates: () => [
{
sessionUpdate: "tool_call",
toolCallId: "read-1",
title: "Read file",
kind: "read",
status: "in_progress",
},
{ sessionUpdate: "tool_call_update", toolCallId: "read-1", status, rawOutput: output },
],
});
const registration = runAcpProvider({
id: "tool-history-acp",
label: "Tool history ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
try {
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "first",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "Read a file" }] },
},
});
await waitForEvent(
events,
(event) => event.type === "session.turn" && event.state === "completed",
);
const rows = events.flatMap((event) =>
event.type === "timeline.item" && event.item.id === "read-1" ? [event.item] : [],
);
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(AgentTimelineItemPayloadSchema.safeParse(row).success).toBe(true);
}
expect(rows.at(-1)).toMatchObject({ type: "tool_call", status, error });
} finally {
await connection.close();
}
},
);
it("serializes configuration mutations", async () => {
let releaseMutation: (() => void) | null = null;
const configOptions = [
+5 -5
View File
@@ -2,7 +2,6 @@ import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
@@ -104,12 +103,13 @@ export interface PluginHookEvents {
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
* The payload carries no history. Read the turn with
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
* started before the read, or when `turnId` is `null`.
*/
timeline: readonly AgentTimelineItem[];
outcome: PluginTurnOutcome;
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -109,11 +109,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/highlight": "0.16.0-beta.1",
"@getpaseo/plugin": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/highlight": "0.16.0-beta.2",
"@getpaseo/plugin": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1529,6 +1529,110 @@ test("does not replace a newer foreground turn after unavailable steer fallback
}
});
test("concurrent interrupting sends each start their own turn in order", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
try {
const prompts = ["first interrupt", "second interrupt", "third interrupt"];
await Promise.all(
prompts.map((prompt) =>
startAgentRun(manager, agentId, prompt, logger, {
replaceRunning: true,
activeTurnBehavior: "interrupt",
runOptions: { clientMessageId: prompt },
}),
),
);
await manager.waitForAgentRunStart(agentId);
expect(session.startPrompts.slice(-3)).toEqual(prompts);
expect(manager.getAgent(agentId)?.activeForegroundTurnId).toBe(
`active-turn-${session.startCount}`,
);
} finally {
await manager.closeAgent(agentId);
rmSync(workdir, { recursive: true, force: true });
}
});
test("Stop can cancel startup while an interrupting send waits behind it", async () => {
const entered = deferred<void>();
const release = deferred<void>();
class SlowStartSession extends SteeringTestSession {
starting = false;
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "slow startup") {
this.starting = true;
entered.resolve();
await release.promise;
this.starting = false;
}
return super.startTurn(prompt);
}
override async interrupt(): Promise<void> {
if (this.starting) release.resolve();
await super.interrupt();
}
}
const session = new SlowStartSession({ provider: "codex", cwd: process.cwd() });
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
try {
await startAgentRun(manager, agentId, "slow startup", logger, { replaceRunning: true });
await entered.promise;
const replacement = startAgentRun(manager, agentId, "queued replacement", logger, {
replaceRunning: true,
});
void replacement.catch(() => undefined);
const stop = manager.cancelAgentRun(agentId);
await vi.waitFor(() => expect(session.interruptCount).toBe(3), { timeout: 1000 });
await stop;
await replacement;
await manager.waitForAgentRunStart(agentId);
expect(session.startPrompts.slice(-2)).toEqual(["slow startup", "queued replacement"]);
} finally {
release.resolve();
await manager.closeAgent(agentId);
rmSync(workdir, { recursive: true, force: true });
}
});
test("an interrupting send replaces a turn whose start never finishes", async () => {
class StalledStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "stalled start") {
this.startPrompts.push(prompt);
return new Promise(() => {});
}
return super.startTurn(prompt);
}
}
const session = new StalledStartSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stalled-start-"));
const client = new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})();
const manager = new AgentManager({
clients: { codex: client },
logger,
rescueTimeouts: { interruptSessionMs: 10 },
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
await startAgentRun(manager, agent.id, "stalled start", logger, { replaceRunning: true });
await startAgentRun(manager, agent.id, "recovery", logger, {
replaceRunning: true,
activeTurnBehavior: "interrupt",
});
expect(session.startPrompts).toEqual(["stalled start", "recovery"]);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("steers a tracked autonomous turn without creating a replacement run", async () => {
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-autonomous-steer-"));
@@ -13230,7 +13334,6 @@ test.each([
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
@@ -844,6 +844,7 @@ export class AgentManager {
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
private readonly replacementMutationTails = new Map<string, Promise<void>>();
private readonly runs = new AgentRunState();
private readonly subscribers = new Set<SubscriptionRecord>();
private readonly idFactory: () => string;
@@ -2112,6 +2113,7 @@ export class AgentManager {
agent.inFlightPermissionResponses.size === 0 &&
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
!this.foregroundMutationTails.has(agent.id) &&
!this.replacementMutationTails.has(agent.id) &&
!this.sessionEventTails.has(agent.id) &&
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
);
@@ -3414,31 +3416,8 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
const snapshot = this.requireAgent(agentId);
if (
snapshot.lifecycle !== "running" &&
!snapshot.activeForegroundTurnId &&
!this.runs.hasRun(agentId)
) {
return this.streamAgent(agentId, prompt, options);
}
const agent = this.requireSessionAgent(agentId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
await this.cancelAgentRunBefore(agentId, "replace");
return this.streamAgent(agentId, prompt, options);
} catch (error) {
const latest = this.agents.get(agentId);
if (latest) {
latest.pendingReplacement = false;
}
throw error;
}
this.requireSessionAgent(agentId);
return this.replaceRunInForegroundLane(agentId, prompt, options);
}
async steerAgentRun(
@@ -3562,18 +3541,26 @@ export class AgentManager {
}
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
return this.runQueuedMutation(this.foregroundMutationTails, agentId, operation);
}
private async runQueuedMutation<T>(
tails: Map<string, Promise<void>>,
agentId: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = tails.get(agentId) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(operation);
const tail = run.then(
() => undefined,
() => undefined,
);
this.foregroundMutationTails.set(agentId, tail);
tails.set(agentId, tail);
try {
return await run;
} finally {
if (this.foregroundMutationTails.get(agentId) === tail) {
this.foregroundMutationTails.delete(agentId);
if (tails.get(agentId) === tail) {
tails.delete(agentId);
}
}
}
@@ -3585,20 +3572,81 @@ export class AgentManager {
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
return this.replaceRunInForegroundLane(agent.id, prompt, options, (live) =>
this.assertSteerAdmissionOwnsTurn(live, expectedTurnId),
);
}
try {
await this.cancelAgentRunBefore(agent.id, "replace");
return this.streamAgent(agent.id, prompt, options);
} catch (error) {
const latest = this.agents.get(agent.id);
if (latest) {
latest.pendingReplacement = false;
/**
* Cancels the current run and reserves its replacement in one foreground mutation, so a
* concurrent replacement or Stop cannot land between the cancel and the new run's start.
*/
private async replaceRunInForegroundLane(
agentId: string,
prompt: AgentPromptInput,
options: AgentRunOptions | undefined,
assertAdmission?: (agent: ActiveManagedAgent) => void,
): Promise<AsyncGenerator<AgentStreamEvent>> {
// Replacements queue in arrival order; each one holds the foreground lane only while
// it cancels and reserves, so Stop is never stuck behind a replacement that is waiting.
return this.runQueuedMutation(this.replacementMutationTails, agentId, () =>
this.replaceRunWhenAdmitted(agentId, prompt, options, assertAdmission),
);
}
private async replaceRunWhenAdmitted(
agentId: string,
prompt: AgentPromptInput,
options: AgentRunOptions | undefined,
assertAdmission?: (agent: ActiveManagedAgent) => void,
): Promise<AsyncGenerator<AgentStreamEvent>> {
let stalledStart: PendingForegroundRun | null = null;
for (;;) {
const admission = await this.runForegroundMutation(agentId, async () => {
// Resolve the agent inside the lane: a reload while this send waited replaces it.
const agent = this.requireSessionAgent(agentId);
// A run that is still starting has no turn to interrupt yet. Canceling it would drop
// its prompt, so wait for it outside the lane, where Stop can still reach it. A start
// that outlasts the rescue timeout is canceled like any other run.
const starting = this.runs.getPendingRun(agent.id);
if (starting && starting.start.status !== "started" && starting !== stalledStart) {
return { status: "starting" as const, run: starting };
}
assertAdmission?.(agent);
if (!this.hasInFlightRun(agent.id)) {
return {
status: "reserved" as const,
iterator: this.streamAgent(agent.id, prompt, options),
};
}
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
const result = await this.cancelAgentRunNow(agent.id);
if (result.status === "refused") {
throw new AgentRunCancellationError(agent.id, "replace");
}
return {
status: "reserved" as const,
iterator: this.streamAgent(agent.id, prompt, options),
};
} catch (error) {
agent.pendingReplacement = false;
throw error;
}
});
if (admission.status === "reserved") {
return admission.iterator;
}
const started = await this.waitWithTimeout({
operation: Promise.race([admission.run.turnStartedPromise, admission.run.settledPromise]),
timeoutMs: this.rescueTimeouts.interruptSessionMs,
});
if (started === "timed_out") {
stalledStart = admission.run;
}
throw error;
}
}
@@ -107,11 +107,17 @@ async function olderHost() {
return { client, socket };
}
function resolverFor(client: DaemonClient, callerBackground: boolean) {
interface Caller {
background: boolean;
provider: string;
currentModeId: string | null;
}
function resolverFor(client: DaemonClient, caller: Caller | null) {
return createHostResolver({
serverId: "origin",
callerAgentId: "caller",
caller: () => ({ background: callerBackground }),
callerAgentId: caller ? "caller" : undefined,
caller: () => caller,
local: {} as HostOperations,
hosts: () => ({
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
@@ -120,6 +126,12 @@ function resolverFor(client: DaemonClient, callerBackground: boolean) {
});
}
const visibleCodexCaller: Caller = {
background: false,
provider: "codex",
currentModeId: "full-access",
};
const createArgs = {
workspaceId: workspace.id,
provider: "codex/gpt-5.4",
@@ -129,7 +141,7 @@ const createArgs = {
test("a visible caller still creates agents on a host without background agents", async () => {
const { client, socket } = await olderHost();
const creating = resolverFor(client, false).use("srv_older", (host) =>
const creating = resolverFor(client, visibleCodexCaller).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
);
creating.catch(() => {});
@@ -143,7 +155,37 @@ test("a background caller's remote child asks the host to update instead of turn
const { client, socket } = await olderHost();
await expect(
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
resolverFor(client, { ...visibleCodexCaller, background: true }).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
),
).rejects.toThrow("Update the host to use background agents.");
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
});
async function forwardedMode(caller: Caller | null, args: Record<string, unknown> = createArgs) {
const { client, socket } = await olderHost();
const creating = resolverFor(client, caller).use("srv_older", (host) =>
host.createAgent(args, {} as never),
);
creating.catch(() => {});
const request = (await socket.createRequest()) as { config: { modeId?: string } };
return request.config.modeId;
}
test("a remote child of the same provider starts in its caller's mode", async () => {
expect(await forwardedMode(visibleCodexCaller)).toBe("full-access");
});
test("an explicit mode wins over the caller's mode on a remote host", async () => {
expect(
await forwardedMode(visibleCodexCaller, { ...createArgs, settings: { modeId: "auto" } }),
).toBe("auto");
});
test("a remote child of another provider leaves its mode to the host", async () => {
expect(await forwardedMode({ ...visibleCodexCaller, provider: "claude" })).toBeUndefined();
});
test("a remote create without a caller agent leaves its mode to the host", async () => {
expect(await forwardedMode(null)).toBeUndefined();
});
@@ -203,8 +203,8 @@ export function createHostResolver(input: {
local: HostOperations;
hosts: () => ToolHosts;
callerAgentId?: string;
/** The destination can't see the caller, so its background defaults resolve before routing. */
caller: () => BackgroundCaller | null;
/** The destination can't see the caller, so its background and mode defaults resolve before routing. */
caller: () => (BackgroundCaller & { provider: string; currentModeId: string | null }) | null;
lockedCwd?: string;
}) {
return {
@@ -222,6 +222,11 @@ export function createHostResolver(input: {
async createAgent(args) {
const parsed = remoteCreate.parse(args);
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
const caller = input.caller();
// Same-provider children inherit the caller's mode, as they would on the same host.
const modeId =
parsed.settings?.modeId ??
(caller?.provider === provider ? (caller.currentModeId ?? undefined) : undefined);
const workspace = await client.findWorkspace(parsed.workspaceId);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
@@ -236,15 +241,13 @@ export function createHostResolver(input: {
model,
cwd: workspace.workspaceDirectory,
title: parsed.title,
modeId: parsed.settings?.modeId,
modeId,
thinkingOptionId: parsed.settings?.thinkingOptionId,
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
// Older hosts reject any background with a workspaceId, so a visible create omits it.
...(inheritAgentBackground(parsed.background, input.caller())
? { background: true }
: {}),
...(inheritAgentBackground(parsed.background, caller) ? { background: true } : {}),
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type {
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ImportProviderSessionContext,
} from "./agent-sdk-types.js";
import { importSessionFromPersistence } from "./provider-session-import.js";
const SESSION_FILE = "/Users/dev/.omp/agent/sessions/project/2026-01-01_session-id.jsonl";
const SESSION_ID = "session-id";
function config(cwd: string): AgentSessionConfig {
return { provider: "omp", cwd };
}
function context(cwd: string): ImportProviderSessionContext {
return { config: config(cwd), storedConfig: config(cwd) };
}
function fakeSession(described: AgentPersistenceHandle | null): AgentSession {
return {
async *streamHistory() {},
describePersistence: () => described,
} as unknown as AgentSession;
}
describe("importSessionFromPersistence", () => {
it("persists the resumed session identity instead of the listed file handle", async () => {
const cwd = "/Users/dev/code/project";
const imported = await importSessionFromPersistence({
provider: "omp",
request: { providerHandleId: SESSION_FILE, cwd },
context: context(cwd),
resumeSession: async () =>
fakeSession({
provider: "omp",
sessionId: SESSION_ID,
nativeHandle: SESSION_FILE,
metadata: { cwd, model: "resumed-model" },
}),
});
expect(imported.persistence).toMatchObject({
provider: "omp",
sessionId: SESSION_ID,
nativeHandle: SESSION_FILE,
metadata: { cwd, provider: "omp", model: "resumed-model" },
});
});
it("keeps the requested handle when the provider describes no identity", async () => {
const cwd = "/Users/dev/code/project";
const imported = await importSessionFromPersistence({
provider: "omp",
request: { providerHandleId: SESSION_FILE, cwd },
context: context(cwd),
resumeSession: async () => fakeSession(null),
});
expect(imported.persistence).toMatchObject({
provider: "omp",
sessionId: SESSION_FILE,
nativeHandle: SESSION_FILE,
});
});
});
@@ -38,12 +38,32 @@ export async function importSessionFromPersistence(input: {
return {
session,
config: storedConfig,
persistence,
persistence: canonicalImportPersistence(persistence, session),
timeline: history.timeline,
providerSubagentEvents: history.providerSubagentEvents,
};
}
/**
* Import handles identify a session by whatever the provider lists, which is not
* always the session id: OMP lists session files. Persist the resumed session's own
* identity instead, so records, dedupe and resume all agree, while the provider's
* native handle still resolves the session.
*/
function canonicalImportPersistence(
requested: AgentPersistenceHandle,
session: ImportedProviderSession["session"],
): AgentPersistenceHandle {
const described = session.describePersistence();
if (!described?.sessionId) return requested;
return {
...described,
provider: requested.provider,
nativeHandle: described.nativeHandle ?? requested.nativeHandle,
metadata: { ...requested.metadata, ...described.metadata },
};
}
function buildImportPersistenceHandle(
provider: AgentProvider,
input: ImportProviderSessionInput,
@@ -147,7 +147,7 @@ describe("Codex thread history", () => {
appServer.assertNoErrors();
});
test("a page too big to send shows a warning row and stops loading", async () => {
test("a page too big to send retries smaller pages, then warns at the item that cannot fit", async () => {
const answers = Array.from({ length: 120 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
@@ -163,11 +163,12 @@ describe("Codex thread history", () => {
);
const loaded = [
{ type: "user_message", text: "One", messageId: "user-1" },
...answers.slice(0, 99).map((answer) => ({
...answers.map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{ type: "user_message", text: "Two", messageId: "user-2" },
{
type: "notification",
level: "warning",
@@ -176,6 +177,12 @@ describe("Codex thread history", () => {
];
try {
expect(timelineItems(await readHistory(session))).toEqual(loaded);
// Each oversized page retries the same cursor at half the size until it fits.
expect(
historyRequests(appServer)
.filter((request) => request.method === "thread/items/list")
.map((request) => (request.params as { limit: number }).limit),
).toEqual([100, 100, 50, 25, 12, 12, 6, 6, 3, 3, 1, 1]);
// The oversized reply failed only its own request; the session still answers.
expect(timelineItems(await readHistory(session))).toEqual(loaded);
} finally {

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