Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0b17d391ef perf(server): use normal worker parallelism on Windows 2026-10-18 05:51:41 +02:00
paseo-bot[bot] 1a6a9fa8d0 fix(claude): keep background child action logs after the parent finishes (#7266)
Late child frames were discarded after the launching turn ended, and parent completion cleared accumulated child actions. Keep card updates attached to the launching turn and release child actions on task completion.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 05:02:33 +02:00
paseo-bot[bot] a7a21d91c2 Keep overlapping directory searches from exhausting the heap (#7265)
* fix(server): keep overlapping directory searches within their scan budget

Construct child paths and branch iterators as the round-robin walk consumes them. Eager sibling expansion retained hundreds of thousands of unvisited children per search and exhausted the heap when requests overlapped.

* test(server): stop directory-search children before fixture cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 03:52:15 +02:00
paseo-bot[bot] b2da58b5bf fix(app): keep chat keyboard scrolling after closing Find (#7263)
Closing Find focused its non-scrolling wrapper, leaving Page Up and Page Down unable to move the feed. Return focus through the viewport handle while preserving the reading position.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 01:51:49 +02:00
paseo-bot[bot] e5d9307cf9 fix(terminal): copy wide characters selected from either half (#7260)
Include the leading cell when a selection starts on a wide character continuation, matching the native renderer highlight. Keep continuation cells out of copied text so real spaces remain unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:55:08 +02:00
paseo-bot[bot] 90c6de0580 fix(app): keep streaming replies complete after reloading (#7256)
A catch-up projection could replace the live reply while leaving its cached prefix in the committed rows. Completion then discarded the complete reply as a duplicate. Reconcile the covered cached prefix together with the live reply.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:01:33 +02:00
paseo-bot[bot]andhelloimdevman 9bd58f66b2 Copy Korean terminal text without extra spaces (#7254)
* fix(terminal): preserve Korean text when copying native terminal cells

* fix(terminal): omit continuation cells from copied text

Native terminal buffers already retain authoritative cell widths. Skip zero-width continuation cells when serializing a selection so Korean text copies with only its real spaces, while preserving the buffer representation.

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>

---------

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 22:57:04 +02:00
paseo-bot[bot]anda-grasso e2ad26d238 Find source-message text in plugin timeline rows (#7253)
* fix(app): find text in rows a timeline plugin draws

Chat find located a replaced message by its source id but counted matches
only inside elements marked as message text, which a plugin renderer never
sets, so a replaced prompt or reply reported no matches. Mark plugin rows and
search the whole row, with each view bounding a match.

* docs(plugins): qualify search in replacement timeline rows

Chat find discovers source-message matches before inspecting a replacement renderer. Document that renderer-added text is not independently indexed.

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>

* fix(app): find visible text within contiguous plugin blocks

A whole-row walk counted closed popover text and reunited text before and after nested blocks. Keep consecutive block groups and exclude plugin text the browser is not drawing.

---------

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 21:58:07 +02:00
paseo-ai[bot] 142cdac8d2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-17 18:18:08 +00:00
Mohamed Boudra c517d07d6f chore(release): cut 0.12.0-beta.2 2026-10-17 20:07:37 +02:00
Mohamed Boudra 6388c466b1 docs(changelog): 0.12.0-beta.2 2026-10-17 20:03:33 +02:00
Mohamed Boudra 3c48ac3ad2 Restore inline-code file path links (#7251)
* fix(app): restore inline-code file path links

Classify complete inline-code spans with the file-link classifier before adapting them to the Markdown engine recognition pattern. Keep bare-text heuristics separate so route punctuation and line fragments work without adding prose false positives.

* perf(app): avoid parsing inline-link source on every reveal

Collect candidate values between equal-length backtick runs and let the Markdown engine own code boundaries. This preserves classifier decisions without reparsing long fenced blocks in JavaScript during streaming.
2026-10-18 00:30:19 +07:00
Mohamed Boudra a4eb0adcf6 test(app): consolidate overlapping Playwright journeys (#7245) 2026-10-17 23:01:36 +07:00
Mohamed Boudra 6ff071a487 Keep the compact sidebar close button beside the first nav item (#7244)
* fix(app): keep the compact sidebar close button beside the first nav row

The close button was absolutely positioned over the nav group, so it covered
the first row's label and hover fill. It now shares a flex row with the first
item, which shrinks to make room, and uses the compact header's icon button so
it gets the same size and round press highlight.

* test(app): cover the compact sidebar close button beside the first nav row
2026-10-17 22:45:08 +07:00
Mohamed Boudra ecd42015b9 Redesign the compact New workspace form as full-width field triggers (#7239)
* Redesign the compact New workspace form as full-width field triggers

Each compact field is one full-width trigger with its label inside, above the
value, so the whole field is the tap target and presses highlight. The stack
stays anchored on the composer with Isolation and Branch rows reserved, so
nothing shifts as they come and go. Import session moves into the compact
header, and the title is gone; the fields name themselves.

The form's presentational code moves out of new-workspace-screen.tsx into
screens/new-workspace/form, where desktop badges and compact fields share one
FieldTrigger.

* Restore the desktop badge pressed colour

Main pressed badges to surface0; the field-trigger extraction folded it into the surface2 hover state.

* Use base text size for compact field values

docs/design.md sets interface text at fontSize.base; hierarchy comes from the muted label above.
2026-10-17 15:27:55 +00:00
Mohamed Boudra 86817a24fc perf(app): fetch project and workspace directories concurrently (#7242) 2026-10-17 22:06:09 +07:00
paseo-bot[bot]andKiraPC 86ab49153a Let Copilot implement plans after selecting Allow All (#7234)
Selecting Allow All now moves Copilot to Agent mode before enabling allow_all, so a session that was in Plan can implement the plan.

Closes #3855.

Co-authored-by: KiraPC <pasqualecarmine.carbone@gmail.com>
2026-10-17 16:54:51 +02:00
Mohamed Boudra 6e5a4682ee fix(markdown): render own-line images as blocks (#7237)
An image alone on its line, inside a paragraph with other lines, rendered inline
on iOS and web: the library only treats sole-child paragraph images as blocks,
and with hardSoftBreaks every newline is a break node. The source adaptation now
gives such images their own paragraph (keeping quote markers and list
indentation), so all platforms render them as blocks with image margins. Images
sharing a line with text stay inline. Verified through the library's md4c wasm
parser in the renderer tests.
2026-10-17 21:36:41 +07:00
Derick WellsandMohamed Boudra f22775a8e1 Default agent-to-agent messages to steering in CLI and MCP (#5164)
* Let agents steer a running turn from send_agent_prompt and paseo send

When an agent used the send_agent_prompt MCP tool on a target that was
mid-turn, sendPromptToAgent ran without activeTurnBehavior, so the
prompt path fell through to replaceAgentRun. That cancelled the target's
active turn and every provider subagent it had in flight. The web UI,
child finish notifications and schedules already pass "steer"; the MCP
tool and the CLI had no way to ask for it.

Add an optional activeTurnBehavior input to send_agent_prompt. It
defaults to "steer" for agent-scoped callers and to "interrupt" for
top-level callers, so existing top-level behaviour is unchanged. Add
--steer to paseo send. Document both and cover them with tests.

* fix(agents): retain finish notifications during steering acknowledgement

Observe completion before dispatch and hold delivery until acceptance. A replacement discards the displaced turn observation and starts a fresh listener before its iterator runs.

* fix(agents): preserve notification ownership when follow-ups fail

Keep the previous observation until dispatch succeeds and suppress duplicate delivery during acknowledgement. Decide whether to retain the running turn from the dispatch result, including side commands requested with interrupt.

* fix(agents): deduplicate notifications across overlapping follow-ups

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-17 20:39:05 +07:00
Mohamed Boudra a0aeca967c fix(app): open explicit file links regardless of extension (#7233)
Authored Markdown destinations were filtered by the filename heuristics intended for inferred text, silently ignoring screenshot links. Resolve explicit destinations directly through the existing workspace opener so reads and failures reach the file pane.
2026-10-17 19:59:49 +07:00
paseo-bot[bot] 5384abdbaa fix(app): open Markdown preview folder links in Files (#7229)
Preview links bypassed the directory-aware opener and opened folder destinations as file tabs. Use the existing opener and explorer reveal actions, retaining preferred file placement and canceling pending checks when leaving the source tab.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 14:49:56 +02:00
paseo-bot[bot] 06cdc5a0b8 fix(app): open nested Markdown folder links without dot prefixes (#7210)
Explicit Markdown hrefs were rejected by the filename heuristic used for inferred text. Let authored nested paths resolve without a recognized extension while preserving inline-code, automatic-link, and domain handling.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 12:55:19 +02:00
Mohamed Boudra 4a32f95848 fix(app): soften the compact composer shadow in light mode (#7214)
The compact composer used a hardcoded box shadow shared by both themes,
so it read as a heavy band on light backgrounds. It now uses the shared
sm shadow token, whose light value is raised from 0.02 to 0.08 opacity
so the lift stays visible. Dark sm is unchanged.
2026-10-17 17:20:06 +07:00
sulthan zahran 1b450e487e fix(claude): don't treat a wrapper command's --version output as Claude Code's version (#7209)
resolveClaudeCodeVersion() probes <configured command> --version and parsed any output with the generic fallback in parseClaudeCodeVersion. With a wrapper command (e.g. cux), that output is the wrapper's own version, which then failed every minimumClaudeCodeVersion gate and silently hid Opus 5.5, Opus 5, Fable 5, Sonnet 5.5 and Haiku 5.5. Only trust output that identifies itself as Claude Code (x.y.z (Claude Code)); anything else falls into the existing unknown-version path that does not gate models.
2026-10-17 11:51:24 +02:00
HelloThereEveryone 74c8172f8b Wait for a plugin reload to finish instead of failing at the request deadline (#6306)
* Wait for plugin reload completion instead of the request deadline

The daemon answers plugin.reload.request only once the plugin is running
again or has failed, and a reload queues behind other plugin lifecycle
work such as an install with a preparation command. The shared client
applied its sixty-second session RPC deadline to that request, so a slow
reload failed with "Timeout waiting for message" while the daemon kept
loading the plugin, inviting a reload the daemon was still running.

Reload now waits for the daemon's own answer. Enable and disable keep the
session deadline, and a real startup failure still reaches the caller.

* Cover a slow plugin reload end to end

The CLI fixture now holds its own git build on a gate file, starts a
reload of an installed plugin behind that queued install, observes the
reload request at the proxy, keeps the gate closed past the client's
former one-minute deadline, and then asserts the reload's real result:
CLI exit 0, the plugin running, and exactly one reactivation. Listing the
plugin during the wait stays readable and does not restart it. A plugin
that cannot start still fails its reload with the daemon's error.

The request proxy that recorded install sources becomes a small observer
that records any session request, so the wait is measured from the
request boundary rather than from process start.

Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts exits 0
in 135 s; the same fixture without the new scenario takes 66 s.

* Document how a reload reports its result

The CLI docs now say `paseo plugin reload` waits for the daemon to stop
and start the plugin, that cancelling the command or losing the
connection does not stop the daemon's work, and that `plugin ls` shows
the previous state until the reload finishes.

The plugin reference records the lifecycle fact behind it: a reload
queues behind the daemon's other plugin work, the daemon answers when
the plugin is running again or has failed, and status reads, Settings and
the logs are not the reload's outcome.

* Cover the Settings reload wait end to end

Adds a browser scenario for the ticket's user-facing half: with a gated
git install holding the daemon's plugin lifecycle queue, the row's Reload
goes out, the test keeps the gate closed past sixty seconds, asserts the
row stays pending with no error feedback, then releases the build and
asserts the daemon's plugin.reload.response and the app's "Reloaded
e2e-plugin" result.

The scenario turns plugins on through the daemon instead of the global
switch, whose transient "Plugins enabled" confirmation is not its
subject. The wait is measured from the reload request frame, which is an
outbound frame, not from the response stream.

Verified: npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.5m); plugin-reload-pending.png,
plugin-reload-past-deadline.png and plugin-reload-completed.png show the
pending row, the surface past the former deadline with no error, and the
finished reload.

* Correct the status guidance for a cancelled reload

Review finding: the note said a listing keeps the plugin's previous
state until the reload finishes, but that only holds while the reload is
still queued. Once it executes, the daemon removes the plugin from the
runtime catalog before stopping it (plugins/runtime.ts:377-382) and
resolvePluginStatus maps an enabled plugin that is not running to
"failed" (plugins/index.ts:71-78), so a listing read in that window
shows failed for a healthy reload.

Both passages now separate the two windows and say that neither a
listing nor the logs report the outcome of the outstanding reload.
The interim "failed" itself is TUC-1401.

* Harden the reload coverage against failures and lost connections

Review findings folded into the tests:

The Settings scenario releases its gated build in a finally block. Before
this, a failing assertion left the build polling forever, the daemon's
plugin lifecycle queue stayed blocked, and the fixture teardown's own
removePlugin timed out behind it. It also asserts the row's actions are
enabled again once the reload settles, next to the result feedback.

The connection-close regression now reconnects and asserts nothing
resends the lost reload; the no-replay invariant already covered for
legacy requests now holds for a pending reload too.

Verified: npx vitest run packages/client/src/daemon-client.test.ts -> 161
passed. npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.4m).

* Let the gated build finish before its files go

Review finding on packages/cli/tests/e2e/plugin-lifecycle.test.ts:450:
the finally block released the gate and deleted the gate directory
without waiting for the install and the reload it had started. The build
polls for the gate file, so cleanup could delete the release before the
child saw it and leave the build waiting until daemon shutdown.

The fixture now keeps both promises, releases the gate, and awaits them
before deleting their files.

Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts -> exit
0, 161 s.

* Read the reload scenario as intent, not transport code

Review finding: the new browser scenario kept frame parsing, selectors,
polling, deadline arithmetic, screenshots and gate cleanup in one body.
docs/testing.md asks for complex setup in reusable helpers.

The wire observation, the gated queue hold, the row-state assertions, the
deadline wait and the surface captures are helpers now, and the body
reads as the flow: open Settings, hold the queue, reload, still pending
past the deadline, release, read the daemon's result.

Verified: npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.4m).

* Keep the reload lifecycle fact in one document

Review finding: the cli.md paragraph repeated the queue and interim-state
explanation that the plugin reference already owns. CLAUDE.md: "One fact,
one doc. Every other mention is a link."

The reference keeps the full explanation; the CLI page states what the
command does and links there.

* Release the gated build when its setup fails

Review finding: holdPluginLifecycleQueue started the install, then
waited up to 30 seconds for the build's started marker. A preparation
that took longer made the helper throw before it returned its cleanup
handle and before the test entered its try block, so nothing wrote the
release file: the build polled forever, the daemon's plugin lifecycle
queue stayed blocked, and the fixture's own removePlugin timed out
behind it.

The helper now owns that failure path: it releases the gate and drains
the install before rethrowing, so a queue it hands out is always
settleable.

Verified on Linux (Chromium, Playwright):
- With the started marker suppressed in a temporary local patch, the run
  fails at the 30-second marker wait inside the helper and the teardown
  still completes (both attempts ~33 seconds, no removePlugin request
  timeout).
- With the marker in place, the scenario passes: 1 passed (1.4m).

* Keep an early install failure out of the unhandled path

Review finding: the marker wait in holdPluginLifecycleQueue ran before
the install promise had a rejection handler, so a preparation that failed
or a connection that closed before the build wrote its marker surfaced as
an unhandled rejection, which fails the run before the helper's own catch
can drain the queue.

The helper attaches a handler the moment the install starts and races the
marker wait against the install, so an early failure reaches the caller
as the daemon's own error, a build that never writes its marker still
fails as a 30-second marker timeout, and every path releases the gate and
drains the install.

Verified on Linux (Chromium, Playwright), patching the build command
locally for each failure branch:
- Early failure (build exits 1): fails in 4.2 s with the daemon's
  DaemonRpcError for plugin.source.install.request, no unhandled
  rejection, teardown completes.
- No marker (build waits for the gate): fails at the 30-second marker
  wait, no unhandled rejection, teardown completes with no removePlugin
  request timeout.
- Unpatched: 1 passed (1.5m).

* Keep a failed launch from ending the run before its cleanup
2026-10-17 10:57:10 +02:00
paseo-bot[bot]andMoy fa70b215a8 Open chat folder links in Files with their contents visible (#7205)
* fix(app): open folders from inline path links in the file explorer

Clicking a folder path in an assistant message opened a file tab that
failed with "Requested path is not a file". Extension-less
workspace-relative paths without a line number are now probed with a
directory listing before opening: directories open the Files explorer
navigated to that folder, everything else opens as a file like before.
Lexical directories (trailing slash, workspace root) now navigate the
explorer to the folder instead of only preloading its listing.

* fix(app): reveal linked folders and their contents

Resolve workspace folder links from directory metadata and expand their ancestor paths in Files. Preserve file and line-reference opening, including extensionless files.

Co-authored-by: Moy <moyfoxther@gmail.com>

* fix(app): cancel stale path opens and reveal hidden folders

Keep pending directory checks inside the path-opening model so a later click or navigation supersedes them. Explicitly revealing a hidden directory also enables the existing hidden-file visibility preference.

---------

Co-authored-by: Moy <moyfoxther@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 09:53:28 +02:00
99 changed files with 4525 additions and 1782 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.12.0-beta.1 - 2026-10-16
## 0.12.0-beta.2 - 2026-10-17
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
@@ -27,8 +27,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
### Plugins
@@ -37,6 +40,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
### Fixed
@@ -51,6 +55,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
@@ -59,7 +64,12 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
@@ -85,9 +95,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
@@ -108,6 +122,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
+1 -1
View File
@@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
## Engine constraints
+1 -1
View File
@@ -1 +1 @@
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/server": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/highlight": "0.12.0-beta.1",
"@getpaseo/plugin": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+376
View File
@@ -0,0 +1,376 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1296.0)
aws-sdk-core (3.257.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
rexml (~> 3.4, >= 3.4.2)
aws-sdk-kms (1.132.0)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.233.2)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.3)
cgi (0.5.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.7.20261007)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.3)
logger
faraday (2.15.0)
faraday-net_http (>= 2.0, < 3.6)
json
logger
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.5.0)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.241.1)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
cgi (~> 0.4)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
erb (>= 4.0, < 7.0)
excon (>= 1.0.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.99)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.5.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 3.4.0, < 4.0.0)
security (~> 0.3)
simctl (~> 1.6.3)
terminal-notifier (>= 3.1.0, < 4.0.0)
terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.109.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.3.1)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.69.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.4.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.1)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.1)
googleauth (1.17.5)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.1.6)
domain_name (~> 0.5)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.2)
jwt (3.3.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.2)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.10.0)
net-protocol (>= 0.3.0)
uri (>= 0.11.1)
net-protocol (0.4.0)
timeout
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.1)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.3.1)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
rexml (3.4.4)
rouge (3.28.0)
rubyzip (3.7.0)
security (0.3.0)
signet (0.22.1)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (3.1.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.3.0)
unicode-emoji (~> 4.3)
unicode-emoji (4.3.0)
uri (1.1.2)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux-gnu
DEPENDENCIES
fastlane (~> 2.234)
multi_json
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.23
+77 -1
View File
@@ -5,6 +5,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -19,6 +20,10 @@ import {
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
import {
expectPromptsDrawnByPlugin,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
@@ -120,6 +125,49 @@ async function closeFind(page: Page) {
.toEqual([]);
}
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
// The scrolling surface has no accessible name; use its existing test id.
const viewport = page.getByTestId("agent-chat-scroll");
await waitForTimelineGeometryToSettle(page);
const before = await viewport.evaluate((element) => element.scrollTop);
await page.keyboard.press(key);
const direction = key === "PageUp" ? -1 : 1;
await expect
.poll(
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
)
.toBeGreaterThan(100);
}
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-scroll-focus-",
title: "Chat Find keyboard scrolling",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await closeFind(page);
await expectChatKeyboardScroll(page, "PageUp");
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
await expectChatKeyboardScroll(page, "PageDown");
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(query(page)).toHaveCount(0);
await expectChatKeyboardScroll(page, "PageUp");
} finally {
await agent.cleanup();
}
});
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
@@ -174,7 +222,7 @@ async function highlightedMessage(page: Page) {
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
@@ -219,6 +267,34 @@ test("counts and steps through every match in the chat, not just the selected me
}
});
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-plugin-prompt-",
title: "Chat Find plugin prompt",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await withPromptTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await expectPromptsDrawnByPlugin(page);
await openChatFind(page);
await query(page).fill("scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "plugin-prompt");
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
});
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
title: "Screenshot file links",
initialPrompt: "Show screenshots",
featureValues: {
mockAssistantResponse:
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
},
});
try {
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
await openAgentRoute(page, session);
await test.step("Open the linked screenshot", async () => {
await openAssistantMarkdownLink(page, "Capture");
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
});
await test.step("Explain why the missing screenshot cannot open", async () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
await session.cleanup();
}
});
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
@@ -0,0 +1,110 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
async function openMarkdownPreview(page: Page) {
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
async function followPreviewLink(page: Page, name: string) {
await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByRole("link", { name, exact: true })
.click();
}
test("Markdown preview folder links reveal Files and file links still open a file", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
await writeFile(
path.join(workspace.repoPath, "docs/guides/guide.md"),
"# Folder link destination\n",
);
await writeFile(
path.join(workspace.repoPath, "README.md"),
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
);
await workspace.navigateTo();
await openMarkdownPreview(page);
await followPreviewLink(page, "Guide");
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
await openMarkdownPreview(page);
await followPreviewLink(page, "Dot");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await openMarkdownPreview(page);
await followPreviewLink(page, "Bare");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
});
async function openReportedFolderLink(page: Page, name: string) {
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
await link.click();
}
async function expectFolderContents(page: Page) {
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
).toBeVisible();
}
for (const folder of [
"./docs/guides",
"./docs/guides/",
"./docs/reports.v1",
"./.github/workflows",
"docs/guides",
"docs/guides/",
"docs/指南",
]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "folder-link-repro-",
title: "Folder link reproduction",
initialPrompt: "Show the folder link",
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
});
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => {
localStorage.setItem(
"panel-state",
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
);
});
await openAgentRoute(page, session);
await openReportedFolderLink(page, "Open folder");
await expectFolderContents(page);
} finally {
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
await session.cleanup();
}
});
}
+19 -48
View File
@@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
clickNewChat,
clickNewTerminal,
@@ -126,6 +124,7 @@ test.describe("Tab creation", () => {
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertNewTabMenuTriggerVisible(page);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
@@ -190,11 +189,18 @@ test.describe("Tab creation", () => {
await expect(agent).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
expect(elapsed).toBeLessThan(3_000);
await expectComposerVisible(page);
const tabsAfter = await getTabTestIds(page);
@@ -202,12 +208,17 @@ test.describe("Tab creation", () => {
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
});
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
expect(elapsed).toBeLessThan(5_000);
await expectTerminalSurfaceVisible(page);
const tabsAfter = await getTabTestIds(page);
@@ -295,13 +306,6 @@ test.describe("Tab creation", () => {
await profileSeed.restore();
}
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => {
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
// Terminal surface should appear within a reasonable budget.
// Note: terminal creation involves a server round-trip, so we allow more time
// than a pure in-memory transition, but it should still be well under 5 seconds.
expect(elapsed).toBeLessThan(5_000);
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
// is that no blank/flash frame appears (tested above).
expect(elapsed).toBeLessThan(3_000);
});
});
@@ -188,3 +188,74 @@ async function measureInlineCode(message: Locator) {
return { codes, right: paragraph.getBoundingClientRect().right };
});
}
test("inline-code paths preserve stable recognition and file navigation", async ({
page,
context,
}, testInfo) => {
const { writeFile, mkdir } = await import("node:fs/promises");
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
const source =
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-path-regression-",
title: "Inline path regression",
initialPrompt: "Show file paths",
featureValues: { mockAssistantResponse: source },
});
try {
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
await writeFile(
`${session.cwd}/src/index.ts`,
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
);
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
for (const path of paths) {
await openInlineRegressionFile(page, path);
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
`Opened ${path}`,
);
}
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-content")
.focus();
await page.keyboard.press("ControlOrMeta+c");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
);
await expect(
page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-selectionBackground"),
).not.toHaveCount(0);
await testInfo.attach("inline-path-line-selection", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await session.cleanup();
}
});
async function openInlineRegressionFile(page: Page, path: string) {
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
await page
.getByTestId("assistant-message")
.getByRole("link", { name: path, exact: true })
.click();
}
@@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
@@ -316,6 +317,47 @@ export default function contribute(plugin) {
return directory;
}
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
const gatedBuildCommand = [
'const fs = require("node:fs");',
'fs.writeFileSync(process.argv[1], "started");',
"const wait = () => {",
" if (fs.existsSync(process.argv[2])) return;",
" setTimeout(wait, 50);",
"};",
"wait();",
].join("\n");
async function createGatedBuildRepository(
root: string,
gate: { started: string; release: string },
): Promise<string> {
const repository = path.join(root, "gated-repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "gated-build-plugin",
description: "Holds the plugin lifecycle queue while its build waits",
requirements: pluginRequirements,
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function installFailedPlugin(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
@@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription(
);
await environment.client.installPluginSource({ source: directory });
}
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
function observePluginReload(page: Page) {
const requests: number[] = [];
const responses: number[] = [];
const isSessionMessage = (payload: unknown, type: string): boolean => {
if (typeof payload !== "string") return false;
try {
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
const message = envelope.type === "session" ? envelope.message : envelope;
return message?.type === type;
} catch {
return false;
}
};
page.on("websocket", (socket) => {
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
socket.on("framesent", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
});
socket.on("framereceived", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
});
});
return {
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
requestedAt: () => requests[0]!,
requestCount: () => requests.length,
};
}
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
async function holdPluginLifecycleQueue(
client: PluginEnvironment["client"],
root: string,
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
const gate = {
started: path.join(root, "build-started"),
release: path.join(root, "build-release"),
};
const repository = await createGatedBuildRepository(root, gate);
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
// The marker wait below can outlive an early failure: a refused preparation, a dropped
// connection, or a build command that exits before writing its marker. Handle that rejection now
// so it never surfaces as an unhandled one, and drain the same promise in finish().
const installDrained = install.catch(() => undefined);
// Every path releases the gate and drains the install: a build still polling for its gate holds
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
const finish = async () => {
await writeFile(gate.release, "go").catch(() => undefined);
await installDrained;
};
try {
// Whichever comes first decides the scenario: the build reports that it started, or the setup
// fails with the daemon's own error instead of a bare marker timeout.
await Promise.race([
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
install,
]);
} catch (error) {
// Setup that never reached the build must not hand the caller a queue it cannot settle.
await finish();
throw error;
}
return {
release: () => writeFile(gate.release, "go"),
settle: finish,
};
}
function pluginRowActions(page: Page, pluginId: string) {
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
}
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
}
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
}
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({
path: testInfo.outputPath(name),
fullPage: true,
animations: "disabled",
});
}
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
const remaining = 66_000 - (Date.now() - requestedAt);
if (remaining > 0) await page.waitForTimeout(remaining);
}
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
page,
pluginEnvironment,
}, testInfo) => {
test.setTimeout(180_000);
const { client, directory } = pluginEnvironment;
const pluginDirectory = await createDirectoryPlugin(
directory,
"e2e-plugin",
"Exercises the complete local plugin lifecycle",
"Plugin v1",
);
// Observe before the app loads: its daemon socket comes up with the page.
const reload = observePluginReload(page);
await gotoAppShell(page);
await openPluginSettings(page);
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: pluginDirectory });
await expectPluginRowSettled(page, "e2e-plugin");
const queue = await holdPluginLifecycleQueue(client, directory);
try {
await selectPluginAction(page, "e2e-plugin", "Reload");
await reload.waitForRequest();
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
await expectPluginRowPending(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
await waitPastFormerReloadDeadline(page, reload.requestedAt());
// Past the former deadline the reload is still pending, the row shows its previous state, and
// no timeout error reached the surface.
await expectPluginRowPending(page, "e2e-plugin");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
await queue.release();
// The daemon's answer only arrives once the plugin really started again; a request timeout
// would have shown up as feedback during the wait above.
await reload.waitForResponse();
expect(reload.requestCount()).toBe(1);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Reloaded e2e-plugin",
);
await expectPluginRowSettled(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
} finally {
await queue.settle();
}
});
@@ -223,7 +223,10 @@ test.describe("Projects settings", () => {
}
});
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
test("edits worktree setup, navigates within the host, and removes a script", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
@@ -236,15 +239,22 @@ test.describe("Projects settings", () => {
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
await test.step("navigation stays inside the selected host", async () => {
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
});
await test.step("removing a script leaves the committed setup unchanged", async () => {
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
test("user edits worktree setup on a non-GitHub remote project", async ({
@@ -259,18 +269,7 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(gitlabRemoteProject);
});
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
test("reopening the edit sheet seeds from the saved project", async ({
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
page,
editableProject,
}) => {
@@ -279,26 +278,26 @@ test.describe("Projects settings", () => {
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await test.step("an icon can be saved without another name change", async () => {
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
});
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
@@ -459,31 +458,4 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page);
});
test("project detail does not render a second host selector", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
@@ -116,28 +116,12 @@ async function exerciseProviderSettingsStack(page: Page) {
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
test.describe("provider settings overlay stack", () => {
test("provider model update age says just now", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-relative-time-",
title: "Provider relative time",
});
try {
await page.clock.install({ time: Date.now() });
await page.setViewportSize(MOBILE_VIEWPORT);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await openProviderSettingsFromModelSelector(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
@@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => {
await expectMobileAgentSidebarHidden(page);
});
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
});
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
page,
}) => {
@@ -1,99 +0,0 @@
import { expect, test } from "../support/fixtures";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
typeInTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
interface CreatedAgentCwdAssertion {
workspaceId: string;
cwd: string | null;
}
async function fetchSingleAgentForWorkspace(
workspace: SeededWorkspace,
): Promise<CreatedAgentCwdAssertion | null> {
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId: workspace.workspaceId,
cwd: agent.cwd,
};
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
await workspace.navigateTo();
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
.toEqual({
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
});
} finally {
await workspace.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
await workspace.navigateTo();
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
});
@@ -3,6 +3,7 @@ import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
submitAgentChatAndExpectCwd,
} from "../support/helpers/workspace-lifecycle";
test.describe("Workspace lifecycle", () => {
@@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => {
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await test.step("submitting a draft creates an agent in the project root", async () => {
await createAgentChatFromLauncher(page);
await submitAgentChatAndExpectCwd(page, workspace);
});
await test.step("creates a terminal in the project root", async () => {
@@ -80,13 +80,6 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
@@ -132,13 +125,6 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout });
}
/** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { clickNewChat, clickNewTerminal } from "./launcher";
import { connectSeedClient } from "./seed-client";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
@@ -30,3 +31,35 @@ export async function createAgentChatFromLauncher(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
}
export async function submitAgentChatAndExpectCwd(
page: Page,
workspace: { workspaceId: string; repoPath: string },
): Promise<void> {
const client = await connectSeedClient();
try {
const message = "cwd draft create";
await composerInput(page).fill(message);
await composerInput(page).press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(
async () => {
const agents = await client.fetchAgents({ scope: "active" });
return agents.entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId)
.map((agent) => ({ workspaceId: agent.workspaceId, cwd: agent.cwd }));
},
{ timeout: 30_000 },
)
.toEqual([{ workspaceId: workspace.workspaceId, cwd: workspace.repoPath }]);
} finally {
await client.close();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -118,8 +118,8 @@ export function ChatFind({
}, [active, isInteractive, model]);
const close = useCallback(() => {
model.close();
root.current?.focus({ preventScroll: true });
}, [model]);
viewportRef.current?.focus?.();
}, [model, viewportRef]);
let status = "";
if (state.phase === "searching") status = t("paneFind.searching");
else if (state.phase === "loading") status = t("paneFind.loading");
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
]);
expect(findRenderedMatches(row, ".another")).toEqual([]);
});
function pluginRow(html: string) {
const row = document.createElement("div");
row.dataset.pluginRow = "true";
row.dataset.messageId = "message-1";
row.innerHTML = html;
document.body.append(row);
return row;
}
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
const row = pluginRow(
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
"needle",
"needle",
]);
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
});
it("leaves rows without the plugin marker limited to marked message text", () => {
const row = document.createElement("div");
row.innerHTML = "<div>needle</div>";
document.body.append(row);
expect(findRenderedMatches(row, "needle")).toEqual([]);
});
it("counts only drawn text in a plugin row, including an opened popover", () => {
const row = pluginRow(
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
row.querySelector<HTMLElement>("[popover]")!.showPopover();
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
});
it("keeps text around nested plugin blocks in separate occurrences", () => {
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
});
@@ -21,28 +21,55 @@ export function findMessageMatches(
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
}
function renderedTextGroups(row: HTMLElement): Text[][] {
const groups: Text[][] = [];
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
// whole row is searched and each div (a React Native View or Text) bounds a match.
const pluginRow = row.dataset.pluginRow === "true";
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
const contents = pluginRow
? [row]
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
for (const content of contents) {
let group: { block: Element; texts: Text[] } | undefined;
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node instanceof Element) {
// Even an empty nested block separates the text before and after it.
if (node.matches(blocks)) group = undefined;
continue;
}
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
if (
pluginRow &&
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
)
continue;
const block = node.parentElement?.closest(blocks) ?? content;
if (group?.block !== block) {
group = { block, texts: [] };
groups.push(group.texts);
}
group.texts.push(node);
}
}
return groups;
}
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
const groups = new Map<Element, Text[]>();
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
const block = node.parentElement?.closest(BLOCKS) ?? content;
const texts = groups.get(block) ?? [];
texts.push(node);
groups.set(block, texts);
}
}
const pattern = query
.trim()
.split(/\s+/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+");
const ranges: Range[] = [];
for (const texts of groups.values()) {
for (const texts of renderedTextGroups(row)) {
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
let text = "";
for (const point of points) {
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
const handle: StreamViewportHandle = {
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
scrollToBottom: () => {
setFollowOutput(true);
cancelPendingStickToBottom();
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-index={virtualRow.index}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
}
export interface StreamViewportHandle {
/** Give keyboard navigation back to the scrolling surface on web. */
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
+48 -62
View File
@@ -87,13 +87,10 @@ import {
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -387,7 +384,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
const { revealDirectory } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
workspaceRoot,
@@ -426,63 +423,53 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setExpandedToolCallGroupIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
(location: WorkspaceFileLocation, disposition: OpenFileDisposition) => {
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ location, disposition });
} else if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
},
);
const openInlineDirectory = useStableEvent((directoryPath: string) => {
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
});
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
workspaceRoot,
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive, agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
}
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({
location,
disposition,
});
return;
}
if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
if (isActive) void inlinePathOpener.open(target, disposition);
},
);
@@ -519,7 +506,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// cell-window and turn-lifecycle renders from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
@@ -10,3 +10,5 @@ export {
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
export { createInlinePathOpener } from "./path-open";
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/** Authored links identify a file; only inferred text needs filename heuristics. */
explicitLink?: boolean;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
@@ -234,7 +236,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -278,6 +280,7 @@ export function parseAssistantFileLink(
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
explicitLink: options.explicitLink,
});
if (relativeTarget) {
return relativeTarget;
@@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
@@ -437,7 +440,7 @@ function parseLocalPathParts(
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
@@ -458,7 +461,7 @@ function parseLocalPathParts(
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
@@ -0,0 +1,104 @@
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { resolveInlinePathOpen } from "./path-open";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("distinguishes dotted and extensionless folders from files using the filesystem", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "folder-link-"));
roots.push(workspaceRoot);
await mkdir(path.join(workspaceRoot, "docs/guides"), { recursive: true });
await mkdir(path.join(workspaceRoot, "docs/reports.v1"));
await writeFile(path.join(workspaceRoot, "Makefile"), "all:\n");
await writeFile(path.join(workspaceRoot, "README.md"), "# Readme\n");
const client = {
listDirectory: (root: string, file: string) => readdir(path.resolve(root, file)),
};
for (const file of ["docs/guides", "docs/reports.v1"]) {
await expect(
resolveInlinePathOpen({ file, directory: "docs", workspaceRoot, client }),
).resolves.toEqual({ kind: "directory", directoryPath: file });
}
for (const file of ["Makefile", "README.md", "missing.md"]) {
await expect(
resolveInlinePathOpen({ file, directory: ".", workspaceRoot, client }),
).resolves.toEqual({ kind: "file" });
}
});
it("preserves explicit directory destinations and file line references", async () => {
const workspaceRoot = "/repo";
await expect(
resolveInlinePathOpen({ directory: "docs/guides", workspaceRoot, client: null }),
).resolves.toEqual({ kind: "directory", directoryPath: "docs/guides" });
await expect(
resolveInlinePathOpen({
file: "docs/guides",
directory: "docs",
lineStart: 12,
workspaceRoot,
client: null,
}),
).resolves.toEqual({ kind: "file" });
});
it("keeps unavailable probes and external paths on the existing file-open path", async () => {
for (const file of ["Makefile", "/external/folder", "C:/external/folder"]) {
await expect(
resolveInlinePathOpen({ file, directory: ".", workspaceRoot: "/repo", client: null }),
).resolves.toEqual({ kind: "file" });
}
});
it("ignores an older probe after a newer file link opens", async () => {
const { createInlinePathOpener } = await import("./path-open");
let rejectProbe!: (error: Error) => void;
const pendingProbe = new Promise<never>((_, reject) => {
rejectProbe = reject;
});
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
rejectProbe(new Error("Not a directory"));
await first;
expect(opened).toEqual(["second.md"]);
});
it("ignores a pending probe when its view is left", async () => {
const { createInlinePathOpener } = await import("./path-open");
let resolveProbe!: (value: unknown) => void;
const pendingProbe = new Promise((resolve) => {
resolveProbe = resolve;
});
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
resolveProbe({});
await pending;
expect(opened).toEqual([]);
});
@@ -0,0 +1,74 @@
import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse";
import {
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type OpenFileDisposition,
} from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path";
interface PathOpenInput {
file?: string;
directory: string;
lineStart?: number;
workspaceRoot: string;
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
isAbsolutePath(input.file) ||
!input.workspaceRoot.trim() ||
!input.client
) {
return { kind: "file" };
}
try {
await input.client.listDirectory(input.workspaceRoot, input.file);
} catch {
return { kind: "file" };
}
return { kind: "directory", directoryPath: input.file };
}
export function createInlinePathOpener(input: {
workspaceRoot: string;
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
}) {
let generation = 0;
return {
async open(target: InlinePathTarget, disposition: OpenFileDisposition) {
const ownGeneration = ++generation;
const normalized = normalizeInlinePathTarget(target.path, input.workspaceRoot);
if (!normalized) return;
const destination = await resolveInlinePathOpen({
...normalized,
lineStart: target.lineStart,
workspaceRoot: input.workspaceRoot,
client: input.client,
});
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (location) input.openFile(location, disposition);
}
},
cancel() {
generation += 1;
},
};
}
@@ -18,6 +18,63 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
"resolves an explicit extensionless folder href: %s",
(href) => {
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
kind: "resolved",
value: {
kind: "file",
target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` },
},
});
},
);
it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => {
expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({
kind: "resolved",
value: { kind: "ignored" },
});
});
it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
"keeps auto-recognized extensionless text ignored: %j",
(origin) => {
expect(
classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT),
).toEqual({
kind: "resolved",
value: { kind: "ignored" },
});
},
);
it.each(["docs/guides?view=all"])(
"does not turn URL-like hrefs into workspace paths: %s",
(href) => {
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
kind: "resolved",
value: { kind: "ignored" },
});
},
);
it.each([
".dev/tablet-review/tablet-agent-draft.png",
"images/capture.jpg",
"downloads/archive.custom",
"assets/LICENSE",
"example.com/guides",
"capture.png",
"missing.png",
])("opens explicit file link %s without filename guessing", (href) => {
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } },
});
});
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
@@ -117,6 +117,7 @@ export function classifyForResolution(
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
+49 -42
View File
@@ -21,8 +21,14 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const ThemedX = withUnistyles(X);
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -545,6 +554,11 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
const closeButton = useMemo(
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
[labels.closeSidebar, closeSidebar],
);
const mobileSidebarInsetStyle = useMemo(
() => ({
paddingTop: insetsTop,
@@ -562,30 +576,11 @@ function MobileSidebar({
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea
placement="inline"
pointerEvents="box-none"
style={styles.mobileCloseButtonRow}
>
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</WindowChromeSafeArea>
<SidebarNavRows
style={styles.sidebarHeaderGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
@@ -626,6 +621,34 @@ function MobileSidebar({
);
}
const NO_SHORTCUT: ShortcutKey[] = [];
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={styles.mobileCloseButton}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={label}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
@@ -856,24 +879,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButtonRow: {
position: "absolute",
top: theme.spacing[3],
left: 0,
right: 0,
zIndex: 2,
alignItems: "flex-end",
},
mobileCloseButton: {
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
// This optical inset puts their painted right edges on the same sidebar rail.
marginRight: theme.spacing[2] + 1.5,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surfaceSidebar,
marginRight: theme.spacing[2],
},
desktopSidebarBorder: {
borderRightWidth: 1,
@@ -1,5 +1,10 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
import { QueryClient } from "@tanstack/react-query";
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
import { describe, expect, it } from "vitest";
@@ -31,6 +36,56 @@ describe("shared Markdown source adaptation", () => {
"> - ![Image](<file:///cache/a.png>)\n\n[picture]: ../a.png",
);
});
it("gives an image alone on its line its own paragraph", () => {
expect(
prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(),
).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it.");
expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe(
"![A](https://x/a.png)\n\n![B](https://x/b.png)",
);
expect(
prepareMarkdownSource(
"Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three",
).render(),
).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three");
});
it("keeps images that share a line with text inline", () => {
const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line";
expect(prepareMarkdownSource(source).render()).toBe(source);
});
it("splits own-line images inside quotes and list items without ending the container", () => {
expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe(
"> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More",
);
expect(
prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(),
).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next");
expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe(
"- ![Shot](https://x/a.png)\n\n Caption line",
);
});
it("renders own-line images as blocks through the library parser", async () => {
const source =
"Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
markInlineImages(tree);
const images: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Image") images.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
).toEqual([
["https://x/a.png", false],
["https://x/i.png", true],
["https://x/q.png", false],
]);
});
it("escapes spoiler delimiters only in prose", () => {
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
"Show \\|\\|words\\|\\| and `||code||`.",
@@ -59,7 +114,7 @@ it.each([
"src/file.ts:10-20",
"src/file.ts(10,2)",
])("recognizes inline paths through the file resolver: %s", (path) => {
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
expect(prepared.linkSource(path, "inline-code")).toEqual({
href: path,
@@ -68,6 +123,106 @@ it.each([
});
});
it("preserves stable inline-code file classification through the library parser", async () => {
const paths = [
"src/app/[id].tsx",
"src/app/(tabs)/index.tsx",
"src/a$b.ts",
".gitignore",
"src/index.ts#L10-L20",
"src/index.ts:10-20",
"src/index.ts:20-10",
"src/index.ts#L0",
"src/index.ts?raw",
"before/after",
"word.word",
"README.md",
"./scripts/run",
"src/file.ts(10,2)",
"src/file.ts lines 10-20",
"/tmp/[route].tsx",
"file:///tmp/file.ts#L10-L20",
];
const source = paths.map((path) => `\`${path}\``).join(" and ");
const prepared = prepareMarkdownSource(source, "/repo");
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const links: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link") links.push(String(node.attributes?.url));
node.children?.forEach(collect);
}
collect(tree);
expect(links).toEqual([
"src/app/[id].tsx",
"src/app/(tabs)/index.tsx",
"src/a$b.ts",
".gitignore",
"src/index.ts#L10-L20",
"src/index.ts:10-20",
"README.md",
"./scripts/run",
"src/file.ts(10,2)",
"/tmp/[route].tsx",
"file:///tmp/file.ts#L10-L20",
]);
expect(
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
});
it("lets the engine own code boundaries while classifying candidate values", async () => {
const source = [
"```ts\n`src/fenced.ts`\n```",
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
"``src/a`b.ts`` and `src/after.ts`",
"\\`src/escaped.ts` and `src/final.ts`",
"`\nsrc/newline.ts\n`",
"[`src/explicit.ts`](https://example.com)",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo");
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual([
"src/app/[id].tsx",
" src/spaced.ts ",
"src/a`b.ts",
"src/after.ts",
"src/escaped.ts",
"src/final.ts",
"src/newline.ts",
]);
});
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
expect(withoutWorkspace.test("README.md")).toBe(false);
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
const prepared = prepareMarkdownSource(source, "/repo");
const native = toNativeLinkRecognition(prepared.linkRecognition);
const pattern = new RegExp(native.inlineCode.pattern);
expect(pattern.test("src/app/[id].tsx")).toBe(true);
expect(pattern.test("src/a+b.ts")).toBe(true);
expect(pattern.test("src/app/i.tsx")).toBe(false);
expect(pattern.test("src/ab.ts")).toBe(false);
});
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
@@ -436,7 +591,7 @@ it.each([
"×/÷",
"2024/10/16",
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`);
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
expect(linkRecognition.inlineCode.test(token)).toBe(false);
});
@@ -461,12 +616,11 @@ it.each([
"123.ts",
"FILE.TS",
"https://example.com/path",
])("recognizes complete paths and URLs in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`);
])("recognizes complete paths and URLs in prose: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
expect(
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
).toEqual([token]);
expect(linkRecognition.inlineCode.test(token)).toBe(true);
});
it.each([
@@ -477,10 +631,13 @@ it.each([
"packages/app",
"123/file",
"src/123",
])("requires stronger path intent in prose while preserving inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`);
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(
`Compare ${token}, then \`${token}\`.`,
"/repo",
);
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
expect(linkRecognition.inlineCode.test(token)).toBe(true);
expect(linkRecognition.inlineCode.test(token)).toBe(false);
});
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
+75 -8
View File
@@ -1,3 +1,4 @@
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
import { normalizeMarkdownHtml } from "./html";
import { gfmTable } from "micromark-extension-gfm-table";
@@ -5,7 +6,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import type { AssistantFileLinkSource } from "@/assistant-file-links";
import { decodeString } from "micromark-util-decode-string";
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Link, Nodes, Root } from "mdast";
import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
@@ -22,7 +23,6 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`;
function createFilePattern(path: string): RegExp {
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
@@ -33,20 +33,46 @@ function createFilePattern(path: string): RegExp {
}
const FILE_PATTERN = createFilePattern(PROSE_PATH);
const INLINE_CODE_FILE_PATTERN = createFilePattern(INLINE_CODE_PATH);
function linkMetadata() {
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
return {
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
},
linkSource: markdownLinkSource,
};
}
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
const paths = new Set<string>();
const previousEnds = new Map<number, number>();
// Collect candidates between consecutive runs of equal length. The engine owns
// Markdown context (escapes, fences, authored links); extra candidates are harmless
// because recognition only applies to complete Code nodes. This avoids an AST
// parse on every streaming reveal, including updates inside long code fences.
for (const match of source.matchAll(/`+/g)) {
const length = match[0].length;
const previousEnd = previousEnds.get(length);
previousEnds.set(length, match.index + length);
if (previousEnd === undefined) continue;
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
// CommonMark code spans replace line endings and strip one surrounding space.
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
value = value.slice(1, -1);
}
const classification = classifyAssistantFileLink(value, { workspaceRoot });
if (classification && classification.kind !== "external") paths.add(value);
}
return paths;
}
export function markdownLinkSource(
url: string,
sourceType?: "markdown" | "text" | "inline-code",
@@ -90,6 +116,7 @@ export function prepareMarkdownSource(
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
const edits: { start: number; end: number; text: string }[] = [];
if (
!source.includes("![") &&
@@ -97,7 +124,7 @@ export function prepareMarkdownSource(
!source.includes("&") &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...linkMetadata(), render: () => source };
return { images, ...metadata, render: () => source };
}
const definitions = new Map<string, { url: string; title?: string | null }>();
@@ -130,10 +157,50 @@ export function prepareMarkdownSource(
end,
});
}
// Line boundaries already turned into paragraph breaks, by offset of their newline.
const splitBoundaries = new Set<number>();
function isImageLike(node: PhrasingContent): boolean {
if (node.type === "image" || node.type === "imageReference") return true;
return (
(node.type === "link" || node.type === "linkReference") &&
node.children.length === 1 &&
isImageLike(node.children[0])
);
}
// An image alone on its line becomes its own paragraph, so every platform renders
// it as a block. The library only treats sole-child paragraph images as blocks;
// with hardSoftBreaks every newline is a break node, which made these inline.
function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) {
for (const child of paragraph.children) {
const childStart = child.position?.start.offset;
const childEnd = child.position?.end.offset;
if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue;
const lineStart = source.lastIndexOf("\n", childStart - 1) + 1;
const newline = source.indexOf("\n", childEnd);
const lineEnd = newline === -1 ? source.length : newline;
const prefix = source.slice(lineStart, childStart);
const suffix = source.slice(childEnd, lineEnd);
const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix);
const aloneAfter = /^\s*\\?$/.test(suffix);
if (!aloneBefore || !aloneAfter) continue;
// Continuation lines inside quotes and lists keep their quote markers on the blank line.
const blank = prefix.replace(/[^>]/g, " ").trimEnd();
const split = (boundary: number) => {
if (splitBoundaries.has(boundary)) return;
splitBoundaries.add(boundary);
// A trailing backslash was a hard break; at a paragraph end it would render literally.
const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary;
edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` });
};
if (lineStart > start) split(lineStart - 1);
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -166,7 +233,7 @@ export function prepareMarkdownSource(
visit(getTree());
return {
images,
...linkMetadata(),
...metadata,
render(
urls: ReadonlyMap<string, string> = new Map(),
unavailableImages: ReadonlySet<string> = new Set(),
@@ -1,8 +1,9 @@
import { router, usePathname } from "expo-router";
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
import { memo, useCallback, useMemo, useRef, type ComponentType } from "react";
import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { PluginSidebarItem } from "@/plugins/sidebar-items";
@@ -30,37 +31,49 @@ interface SidebarNavRowProps {
interface SidebarNavRowsProps extends SidebarNavRowProps {
/** Style for the group wrapper, which the sidebar owns. */
style?: StyleProp<ViewStyle>;
/** Sits to the right of the first row, which shrinks to make room. */
firstRowTrailing?: ReactNode;
}
/**
* Top-level sidebar navigation, ordered and filtered by the user's
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
* wrapper — when every item is hidden.
* wrapper — when every item is hidden and there is no `firstRowTrailing`.
*/
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) {
const { items } = useSidebarNavItems("header");
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
const groupRef = useRef<View | null>(null);
if (visibleItems.length === 0) return null;
if (visibleItems.length === 0 && !firstRowTrailing) return null;
const [firstRow, ...restRows] = visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItem
key={item.key}
group={item.group}
section="header"
fallbackAnchorRef={groupRef}
onBeforeNavigate={onBeforeNavigate}
/>
);
}
const Row = BUILTIN_ROWS[item.id];
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
});
return (
<View ref={groupRef} collapsable={false} style={style}>
{visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItem
key={item.key}
group={item.group}
section="header"
fallbackAnchorRef={groupRef}
onBeforeNavigate={onBeforeNavigate}
/>
);
}
const Row = BUILTIN_ROWS[item.id];
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
})}
{firstRowTrailing ? (
<View style={styles.firstRow}>
<View style={styles.firstRowItem}>{firstRow}</View>
{firstRowTrailing}
</View>
) : (
firstRow
)}
{restRows}
</View>
);
}
@@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps
search: SidebarSearchRow,
schedules: SidebarSchedulesRow,
};
const styles = StyleSheet.create({
firstRow: {
flexDirection: "row",
alignItems: "center",
},
firstRowItem: {
flex: 1,
minWidth: 0,
},
});
+1 -1
View File
@@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
// Lifts the composer off the chat it sits under on phones.
inputWrapperCompact: {
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
...theme.shadow.sm,
},
// Dotted says "this surface is the same box, but there is nothing to type
// into it" without swapping the border colour, which reads as an error state.
+22 -1
View File
@@ -1,6 +1,6 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries } from "./visibility";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -201,3 +201,24 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
});
return sorted;
}
/** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: {
directoryPath: string;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
if (isHiddenExplorerPath(input.directoryPath)) input.showHiddenFiles();
const paths = ["."];
if (input.directoryPath !== ".") {
const segments = input.directoryPath.split("/").filter(Boolean);
for (let length = 1; length <= segments.length; length += 1) {
paths.push(segments.slice(0, length).join("/"));
}
}
for (const path of paths) {
if (!(await input.requestDirectoryListing(path))) return;
}
input.expandPaths(paths);
}
@@ -6,6 +6,8 @@ import {
type ExplorerDirectory,
} from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree";
import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState {
@@ -172,6 +174,27 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const revealDirectory = useCallback(
async (directoryPath: string) => {
if (!workspaceStateKey) return;
await revealExplorerDirectory({
directoryPath,
requestDirectoryListing,
showHiddenFiles: () => {
const panel = usePanelStore.getState();
if (!panel.explorerShowHiddenFiles) panel.toggleExplorerShowHiddenFiles();
},
expandPaths: (paths) =>
usePanelStore
.getState()
.setExpandedPathsForWorkspace(workspaceStateKey, (current) =>
Array.from(new Set([...current, ...paths])),
),
});
},
[requestDirectoryListing, workspaceStateKey],
);
const requestFilePreview = useCallback(
async (path: string) => {
if (!workspaceStateKey) {
@@ -354,6 +377,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return {
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
+2 -1
View File
@@ -1334,8 +1334,9 @@ export const ar: TranslationResources = {
},
fields: {
project: "المشروع",
base: "الأساس",
branch: "الفرع",
baseNotApplicable: "غير قابل للتطبيق",
host: "المضيف",
},
titlePlaceholder: "العنوان (اختياري)",
errors: {
+2 -1
View File
@@ -1342,8 +1342,9 @@ export const en = {
},
fields: {
project: "Project",
base: "Base",
branch: "Branch",
baseNotApplicable: "Not applicable",
host: "Host",
},
titlePlaceholder: "Title (optional)",
errors: {
+2 -1
View File
@@ -1370,8 +1370,9 @@ export const es: TranslationResources = {
},
fields: {
project: "Proyecto",
base: "Base",
branch: "Rama",
baseNotApplicable: "No aplicable",
host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
+2 -1
View File
@@ -1363,8 +1363,9 @@ export const fr: TranslationResources = {
},
fields: {
project: "Projet",
base: "Base",
branch: "Branche",
baseNotApplicable: "Non applicable",
host: "Hôte",
},
titlePlaceholder: "Titre (facultatif)",
errors: {
+2 -1
View File
@@ -1348,8 +1348,9 @@ export const ja: TranslationResources = {
},
fields: {
project: "プロジェクト",
base: "ベース",
branch: "ブランチ",
baseNotApplicable: "該当なし",
host: "ホスト",
},
titlePlaceholder: "タイトル(任意)",
errors: {
+2 -1
View File
@@ -1341,8 +1341,9 @@ export const ko: TranslationResources = {
},
fields: {
project: "프로젝트",
base: "기준",
branch: "브랜치",
baseNotApplicable: "해당 없음",
host: "호스트",
},
titlePlaceholder: "제목(선택 사항)",
errors: {
+2 -1
View File
@@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = {
},
fields: {
project: "Projeto",
base: "Base",
branch: "Branch",
baseNotApplicable: "Não aplicável",
host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
+2 -1
View File
@@ -1352,8 +1352,9 @@ export const ru: TranslationResources = {
},
fields: {
project: "Проект",
base: "Базовая ветка",
branch: "Ветка",
baseNotApplicable: "Неприменимо",
host: "Хост",
},
titlePlaceholder: "Заголовок (необязательно)",
errors: {
+2 -1
View File
@@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = {
},
fields: {
project: "项目",
base: "基线",
branch: "分支",
baseNotApplicable: "不适用",
host: "主机",
},
titlePlaceholder: "标题(可选)",
errors: {
@@ -1207,6 +1207,60 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it.each(["projects", "workspaces"] as const)(
"requests projects and workspaces together and commits once when %s responds first",
async (firstResponse) => {
const serverId = `parallel-directory-${firstResponse}`;
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
const releaseProjects = client.holdProjectFetch();
const releaseWorkspaces = client.holdWorkspaceFetch();
const project = {
projectId: "parallel-project",
projectDisplayName: "Project baseline",
projectRootPath: "/repo/parallel",
projectKind: "git" as const,
};
const projects: ProjectListResult = { requestId: "projects", projects: [project] };
const workspaces: WorkspaceFetchResult = {
requestId: "workspaces",
entries: [createWorkspaceEntry("parallel-workspace")],
emptyProjects: [{ ...project, projectDisplayName: "Newer workspace overlay" }],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
const refresh = directory.refreshWorkspaces();
try {
await expect.poll(() => client.listProjectsCalls).toBe(1);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
if (firstResponse === "projects") releaseProjects(projects);
else releaseWorkspaces(workspaces);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(useSessionStore.getState().sessions[serverId].workspaces.size).toBe(0);
expect(useSessionStore.getState().sessions[serverId].projects.size).toBe(0);
releaseProjects(projects);
releaseWorkspaces(workspaces);
await refresh;
const session = useSessionStore.getState().sessions[serverId];
expect(session.workspaces.has("parallel-workspace")).toBe(true);
expect(session.projects.get(project.projectId)?.projectDisplayName).toBe(
"Newer workspace overlay",
);
} finally {
releaseProjects(projects);
releaseWorkspaces(workspaces);
await refresh;
directory.dispose();
}
},
);
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
const serverId = "superseded-workspace-baseline";
const { client, directory } = createDirectory(serverId);
@@ -563,16 +563,20 @@ export class DirectorySync {
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
const supportsProjectList = serverInfo?.features?.projectList === true;
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
if (supportsProjectList) {
await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync);
}
await this.fetchWorkspaceSnapshot(
client,
source,
transaction,
input?.subscribe === true,
supportsDirectorySync,
);
const projectsReady = supportsProjectList
? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync)
: Promise.resolve();
await Promise.all([
projectsReady,
this.fetchWorkspaceSnapshot(
client,
source,
transaction,
input?.subscribe === true,
supportsDirectorySync,
projectsReady,
),
]);
if (!supportsProjectList) {
this.buildLegacyProjectSnapshot(transaction.snapshot);
(transaction.snapshot.syncModes ??= {}).projects =
@@ -592,6 +596,7 @@ export class DirectorySync {
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
supportsDirectorySync: boolean,
projectsReady: Promise<void>,
): Promise<void> {
let cursor: string | null = null;
let subscribe = initialSubscribe;
@@ -618,6 +623,9 @@ export class DirectorySync {
});
payload = await subscription.ready;
} else payload = await client.fetchWorkspaces(query);
// Fetch both directories together, but apply the project baseline before the
// workspace page's empty-project overlays, regardless of response order.
await projectsReady;
this.assertWorkspaceTransactionCurrent(client, source, transaction);
applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null);
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* `badge` is the desktop meta row: a borderless pill that names its field in a tooltip.
* `field` is the compact form: a full-width filled box with the field's label inside, above the
* value, so the whole field is one tap target.
*/
export type FieldTriggerVariant = "badge" | "field";
const ThemedChevronDown = withUnistyles(ChevronDown);
const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
// Extra-muted: the chevron is an affordance, not information, and sits behind its label.
function Chevron(): ReactElement {
return (
<View style={styles.chevron}>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColor} />
</View>
);
}
const chevron = <Chevron />;
export interface FieldTriggerContentProps {
variant: FieldTriggerVariant;
/** The field's name, shown inside a `field`. */
label: string;
icon: ReactNode;
value: string;
/** Secondary text after the value, such as a terminal profile's name. */
detail?: string;
}
export function FieldTriggerContent({
variant,
label,
icon,
value,
detail,
}: FieldTriggerContentProps): ReactElement {
const valueLine = (
<>
<View style={styles.iconBox}>{icon}</View>
<Text style={variant === "field" ? styles.fieldValue : styles.value} numberOfLines={1}>
{value}
{detail ? <Text style={styles.detail}> {detail}</Text> : null}
</Text>
</>
);
if (variant === "badge") {
return (
<>
{valueLine}
{chevron}
</>
);
}
return (
<>
<View style={styles.fieldBody}>
<Text style={styles.fieldLabel} numberOfLines={1}>
{label}
</Text>
<View style={styles.fieldValueLine}>{valueLine}</View>
</View>
{chevron}
</>
);
}
/** Pressable style for a field trigger, for triggers that bring their own Pressable. */
export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) {
return useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => [
variant === "field" ? styles.field : styles.badge,
!disabled && Boolean(state.hovered) && styles.highlighted,
!disabled &&
state.pressed &&
(variant === "field" ? styles.highlighted : styles.badgePressed),
disabled && styles.disabled,
],
[disabled, variant],
);
}
export interface FieldTriggerProps extends FieldTriggerContentProps {
/** What a screen reader announces; defaults to `label`. */
accessibilityLabel?: string;
onPress: () => void;
disabled?: boolean;
/** Desktop hover hint; badges only, since a field shows its label. */
tooltip?: string;
testID?: string;
}
/** A field trigger that anchors a Combobox. */
export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTrigger(
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
ref,
): ReactElement {
const style = useFieldTriggerStyle(content.variant, disabled);
const trigger = (
<ComboboxTrigger
ref={ref}
chevron={null}
block={content.variant === "field"}
testID={testID}
onPress={onPress}
disabled={disabled}
style={style}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? content.label}
>
<FieldTriggerContent {...content} />
</ComboboxTrigger>
);
if (content.variant === "field" || !tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
{trigger}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
});
// Height of a badge. The desktop row sizes from it.
export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight;
// Height of a compact field: label line plus value line. The compact stack reserves rows from it.
export const FIELD_HEIGHT = 56;
const styles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
height: FIELD_BADGE_HEIGHT,
maxWidth: 240,
overflow: "hidden",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
field: {
flexDirection: "row",
alignItems: "center",
height: FIELD_HEIGHT,
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
gap: theme.spacing[2],
},
highlighted: {
backgroundColor: theme.colors.surface2,
},
badgePressed: {
backgroundColor: theme.colors.surface0,
},
disabled: {
opacity: 0.6,
},
// Optical: the chevron's ink sits high in its box.
chevron: {
flexShrink: 0,
transform: [{ translateY: 1 }],
},
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
value: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
fieldBody: {
flex: 1,
minWidth: 0,
gap: theme.spacing[0.5],
},
fieldLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
fieldValueLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
fieldValue: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
detail: {
color: theme.colors.foregroundExtraMuted,
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -0,0 +1,49 @@
import type { ReactElement } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export const folderIcon = <ThemedFolder size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const addProjectIcon = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const branchIcon = <ThemedGitBranch size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const pullRequestIcon = <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={mutedColor} />;
// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size
// token, so it stays a literal sized to the box.
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
export function ProjectIcon({
projectViewKey,
projectName,
iconDataUri,
}: {
projectViewKey: string;
projectName: string;
iconDataUri: string | null;
}): ReactElement {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderLabel.charAt(0).toUpperCase() || "?"}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.fallbackText}
/>
);
}
const styles = StyleSheet.create((theme) => ({
fallbackText: {
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -0,0 +1,392 @@
import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
import { HostStatusDot } from "@/components/host-status-dot";
import { HostPicker } from "@/components/hosts/host-picker";
import type { LaunchTarget } from "@/new-workspace-launch/target";
import type { HostProjectListItem } from "@/projects/host-projects";
import type { HostProfile } from "@/types/host-connection";
import type { PickerItem } from "../../new-workspace-picker-item";
import {
FIELD_BADGE_HEIGHT,
FIELD_HEIGHT,
FieldTrigger,
type FieldTriggerVariant,
} from "./field-trigger";
import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
import { LaunchControl } from "./launch-control";
import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options";
type Isolation = "local" | "worktree";
type RenderOption = NonNullable<ComboboxProps["renderOption"]>;
interface PickerControl {
anchorRef: RefObject<View | null>;
open: () => void;
openState: boolean;
onOpenChange: (open: boolean) => void;
}
export interface NewWorkspaceFormProps {
isCompact: boolean;
isPending: boolean;
project: PickerControl & {
options: ComboboxOption[];
projectByOptionId: Map<string, HostProjectListItem>;
iconDataByProjectViewKey: Map<string, string | null>;
selectedProject: HostProjectListItem | null;
selectedOptionId: string;
triggerLabel: string;
selectedServerId: string;
/** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */
supportsWorkspaceMultiplicity: boolean;
onSelect: (id: string) => void;
onAddProject: () => void;
};
host: PickerControl & {
allHosts: HostProfile[];
selectedServerId: string;
onSelect: (id: string) => void;
onAddHost: () => void;
};
isolation: PickerControl & {
effectiveIsolation: Isolation;
canCreateWorktree: boolean;
onSelect: (isolation: Isolation) => void;
};
base: PickerControl & {
selectedSourceDirectory: string | null;
selectedItem: PickerItem | null;
triggerLabel: string;
options: ComboboxOption[];
itemById: Map<string, PickerItem>;
selectedOptionId: string;
onSelect: (id: string) => void;
setSearchQuery: (query: string) => void;
emptyText: string;
showRefPicker: boolean;
};
launch: {
serverId: string;
target: LaunchTarget;
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled: boolean;
};
}
function hostOptionTestID(serverId: string): string {
return `new-workspace-host-picker-option-${serverId}`;
}
function isProjectSelectable(
project: HostProjectListItem,
supportsWorkspaceMultiplicity: boolean,
): boolean {
return (
supportsWorkspaceMultiplicity ||
project.hosts.some((host) => host.worktreeSupport !== "unsupported")
);
}
function useProjectPicker(
project: NewWorkspaceFormProps["project"],
isPending: boolean,
): { renderOption: RenderOption; footer: ReactNode } {
const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project;
const { supportsWorkspaceMultiplicity, onAddProject } = project;
const renderOption = useCallback<RenderOption>(
(row) => {
const item = projectByOptionId.get(row.option.id);
return (
<ProjectOption
{...row}
project={item}
iconDataUri={item ? (iconDataByProjectViewKey.get(item.viewKey) ?? null) : null}
selectedServerId={selectedServerId}
disabled={isPending || !item || !isProjectSelectable(item, supportsWorkspaceMultiplicity)}
/>
);
},
[
iconDataByProjectViewKey,
isPending,
projectByOptionId,
selectedServerId,
supportsWorkspaceMultiplicity,
],
);
const footer = useMemo(() => <AddProjectOption onPress={onAddProject} />, [onAddProject]);
return { renderOption, footer };
}
function useIsolationPicker(
isolation: NewWorkspaceFormProps["isolation"],
isPending: boolean,
): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } {
const { t } = useTranslation();
const { canCreateWorktree, onSelect, onOpenChange } = isolation;
// "New worktree" is omitted entirely (not disabled) when the project isn't a
// git checkout, since worktree isolation is impossible there.
const options = useMemo<ComboboxOption[]>(() => {
const local = { id: "local", label: t("newWorkspace.isolation.local") };
if (!canCreateWorktree) return [local];
return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }];
}, [canCreateWorktree, t]);
const handleSelect = useCallback(
(id: string) => {
onSelect(id === "worktree" ? "worktree" : "local");
onOpenChange(false);
},
[onOpenChange, onSelect],
);
const renderOption = useCallback<RenderOption>(
(row) => <IsolationOption {...row} disabled={isPending} />,
[isPending],
);
return { options, onSelect: handleSelect, renderOption };
}
function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolean): RenderOption {
return useCallback<RenderOption>(
(row) => <RefOption {...row} item={itemById.get(row.option.id)} disabled={isPending} />,
[isPending, itemById],
);
}
/**
* The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
* of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
* that does not apply to the selected project is left out, and Host only shows with a second host.
*/
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
const { t } = useTranslation();
const { isCompact, isPending, project, host, isolation, base, launch } = props;
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
const projectPicker = useProjectPicker(project, isPending);
const isolationPicker = useIsolationPicker(isolation, isPending);
const renderRefOption = useRefRenderOption(base.itemById, isPending);
const showHost = host.allHosts.length > 1;
const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const selectedProject = project.selectedProject;
const selectedProjectIconUri = selectedProject
? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null)
: null;
const projectIcon = useMemo(
() =>
selectedProject ? (
<ProjectIcon
projectViewKey={selectedProject.viewKey}
projectName={selectedProject.projectName}
iconDataUri={selectedProjectIconUri}
/>
) : (
folderIcon
),
[selectedProject, selectedProjectIconUri],
);
const hostIcon = useMemo(
() => <HostStatusDot serverId={host.selectedServerId} />,
[host.selectedServerId],
);
const isWorktree = isolation.effectiveIsolation === "worktree";
const isolationValue = isWorktree
? t("newWorkspace.isolation.worktree")
: t("newWorkspace.isolation.local");
const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon;
const projectField = (
<FieldTrigger
ref={project.anchorRef}
variant={variant}
label={t("newWorkspace.fields.project")}
accessibilityLabel="Workspace project"
tooltip={t("newWorkspace.tooltips.project")}
icon={projectIcon}
value={project.triggerLabel}
onPress={project.open}
disabled={isPending}
testID="new-workspace-project-picker-trigger"
/>
);
const hostField = showHost ? (
<HostPicker
hosts={host.allHosts}
value={host.selectedServerId}
onSelect={host.onSelect}
open={host.openState}
onOpenChange={host.onOpenChange}
anchorRef={host.anchorRef}
includeAddHost={isCompact}
onAddHost={host.onAddHost}
searchable={false}
title={t("newWorkspace.fields.host")}
desktopPlacement="bottom-start"
desktopMinWidth={200}
hostOptionTestID={hostOptionTestID}
>
<FieldTrigger
ref={host.anchorRef}
variant={variant}
label={t("newWorkspace.fields.host")}
tooltip={t("newWorkspace.tooltips.host")}
icon={hostIcon}
value={selectedHostLabel}
onPress={host.open}
disabled={isPending}
testID="host-picker-trigger"
/>
</HostPicker>
) : null;
const isolationField = isolation.canCreateWorktree ? (
<FieldTrigger
ref={isolation.anchorRef}
variant={variant}
label={t("newWorkspace.isolation.label")}
accessibilityLabel="Workspace isolation"
tooltip={t("newWorkspace.tooltips.isolation")}
icon={isWorktree ? branchIcon : folderIcon}
value={isolationValue}
onPress={isolation.open}
disabled={isPending}
testID="workspace-create-isolation-trigger"
/>
) : null;
const baseField = base.showRefPicker ? (
<FieldTrigger
ref={base.anchorRef}
variant={variant}
label={t("newWorkspace.fields.branch")}
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
tooltip={t("newWorkspace.tooltips.startingRef")}
icon={refIcon}
value={base.triggerLabel}
onPress={base.open}
disabled={isPending || !base.selectedSourceDirectory}
testID="new-workspace-ref-picker-trigger"
/>
) : null;
const launchField = (
<LaunchControl
variant={variant}
serverId={launch.serverId}
target={launch.target}
onChange={launch.onChange}
profiles={launch.profiles}
disabled={launch.disabled}
/>
);
const pickers = (
<>
<Combobox
options={project.options}
value={project.selectedOptionId}
onSelect={project.onSelect}
searchable
searchPlaceholder="Search projects"
title={t("newWorkspace.fields.project")}
open={project.openState}
onOpenChange={project.onOpenChange}
desktopPlacement="bottom-start"
desktopMinWidth={360}
anchorRef={project.anchorRef}
emptyText="No projects available."
renderOption={projectPicker.renderOption}
footer={projectPicker.footer}
/>
<Combobox
options={isolationPicker.options}
value={isolation.effectiveIsolation}
onSelect={isolationPicker.onSelect}
title={t("newWorkspace.isolation.label")}
open={isolation.openState}
onOpenChange={isolation.onOpenChange}
desktopPlacement="bottom-start"
anchorRef={isolation.anchorRef}
renderOption={isolationPicker.renderOption}
/>
<Combobox
options={base.options}
value={base.selectedOptionId}
onSelect={base.onSelect}
searchable
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
title={t("newWorkspace.refPicker.title")}
open={base.openState}
onOpenChange={base.onOpenChange}
onSearchQueryChange={base.setSearchQuery}
desktopPlacement="bottom-start"
anchorRef={base.anchorRef}
emptyText={base.emptyText}
renderOption={renderRefOption}
/>
</>
);
if (isCompact) {
return (
<View testID="new-workspace-ref-picker-row" style={styles.compactStack}>
{projectField}
{hostField}
{isolationField}
{baseField}
{launchField}
{/* Isolation and Base come and go with the project; their rows stay reserved so the
stack keeps one height and nothing above the composer jumps. */}
{isolationField ? null : <View style={styles.reservedField} pointerEvents="none" />}
{baseField ? null : <View style={styles.reservedField} pointerEvents="none" />}
{pickers}
</View>
);
}
return (
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
<View style={styles.desktopControl}>{projectField}</View>
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
<View style={styles.launchSpacer} pointerEvents="none" />
{launchField}
{pickers}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
compactStack: {
marginBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
},
reservedField: {
height: FIELD_HEIGHT,
},
desktopRow: {
flexDirection: "row",
alignItems: "center",
minHeight: FIELD_BADGE_HEIGHT,
// Matches the gap between the composer and the Import session pill below it.
marginBottom: theme.spacing[4],
// The badge adds its own left padding; offset it so the project icon's left
// edge lands exactly on the "New workspace" title's left edge. The trailing
// inset mirrors it so the launch chip stops on the composer's inner content
// rather than running out to the composer's border.
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
},
desktopControl: {
minWidth: 0,
flexShrink: 1,
},
// Pushes the launch control to the trailing edge of the desktop meta row.
launchSpacer: {
flex: 1,
},
}));
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useRouter } from "expo-router";
import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
import { MessageCircle, SquareTerminal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
@@ -30,14 +30,17 @@ import {
resolveLaunchProfile,
terminalLaunchTarget,
type LaunchTarget,
} from "./target";
} from "@/new-workspace-launch/target";
import {
FieldTriggerContent,
useFieldTriggerStyle,
type FieldTriggerVariant,
} from "./field-trigger";
const ThemedMessageCircle = withUnistyles(MessageCircle);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const chatIcon = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
@@ -83,12 +86,7 @@ export interface LaunchControlProps {
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled?: boolean;
/**
* The meta row's shared badge style. Passed in rather than redeclared so this
* trigger stays pixel-identical to the project, host, and branch triggers it
* sits beside; a local copy drifts the moment one of them changes.
*/
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
variant: FieldTriggerVariant;
}
function TriggerIcon({
@@ -108,8 +106,8 @@ function TriggerIcon({
}
/**
* The meta-row control choosing what New workspace submits to: the chat agent,
* or a terminal. One trigger shaped like every other chip on that row, opening
* The form control choosing what New workspace submits to: the chat agent,
* or a terminal. One trigger shaped like every other field trigger, opening
* a menu whose Chat and Terminal groups are what keep a terminal profile named
* "Codex" from reading as a chat Codex agent.
*/
@@ -119,13 +117,18 @@ export function LaunchControl({
onChange,
profiles,
disabled = false,
badgePressableStyle,
variant,
}: LaunchControlProps) {
const { t } = useTranslation();
const router = useRouter();
const [open, setOpen] = useState(false);
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
const triggerStyle = useFieldTriggerStyle(variant, disabled);
const triggerIcon = useMemo(
() => <TriggerIcon target={target} profile={selectedProfile} />,
[selectedProfile, target],
);
// A profile's own name and logo say nothing about where it runs: a row
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
@@ -149,32 +152,26 @@ export function LaunchControl({
return (
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
{/* The trigger is the Pressable itself, styled by the meta row's shared
badge style, so it lands pixel-identical to the project, host, and
branch chips it sits beside. */}
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
tooltip anchors to a wrapping View instead. Suppressed while the menu
is open so the two surfaces never overlap. */}
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<Tooltip delayDuration={300} enabledOnDesktop={!open && variant === "badge"}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
testID="new-workspace-launch-trigger"
disabled={disabled}
style={badgePressableStyle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("newWorkspace.tooltips.launch")}
>
<View style={styles.iconBox}>
<TriggerIcon target={target} profile={selectedProfile} />
</View>
<Text style={styles.label} numberOfLines={1}>
{triggerLabel}
{selectedProfile ? (
<Text style={styles.profileName}> {selectedProfile.name}</Text>
) : null}
</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
<FieldTriggerContent
variant={variant}
label={t("newWorkspace.launch.title")}
icon={triggerIcon}
value={triggerLabel}
detail={selectedProfile?.name}
/>
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
@@ -232,27 +229,10 @@ export function LaunchControl({
}
const styles = StyleSheet.create((theme) => ({
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
label: {
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
profileName: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundExtraMuted,
},
command: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
@@ -0,0 +1,166 @@
import { useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects";
import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item";
import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
interface OptionRowState {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}
function IconBox({ children }: { children: ReactElement }): ReactElement {
return <View style={styles.iconBox}>{children}</View>;
}
export function ProjectOption({
option,
selected,
active,
onPress,
project,
iconDataUri,
selectedServerId,
disabled,
}: OptionRowState & {
project: HostProjectListItem | undefined;
iconDataUri: string | null;
selectedServerId: string;
disabled: boolean;
}): ReactElement {
const leadingSlot = useMemo(
() =>
project ? (
<IconBox>
<ProjectIcon
projectViewKey={project.viewKey}
projectName={project.projectName}
iconDataUri={iconDataUri}
/>
</IconBox>
) : undefined,
[iconDataUri, project],
);
if (!project) return <View key={option.id} />;
return (
<ComboboxItem
testID={`new-workspace-project-picker-option-${project.viewKey}`}
label={project.projectName}
description={
getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir
}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
const { t } = useTranslation();
const openProjectKeys = useShortcutKeys("new-agent");
const shortcut = useMemo(
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
[openProjectKeys],
);
return (
<ComboboxItem
testID="new-workspace-project-picker-add-project"
label={t("sidebar.actions.addProject")}
onPress={onPress}
leadingSlot={addProjectIcon}
trailingSlot={shortcut}
/>
);
}
const worktreeLeading = <IconBox>{branchIcon}</IconBox>;
const localLeading = <IconBox>{folderIcon}</IconBox>;
export function IsolationOption({
option,
selected,
active,
onPress,
disabled,
}: OptionRowState & { disabled: boolean }): ReactElement {
return (
<ComboboxItem
testID={`workspace-create-isolation-${option.id}`}
label={option.label}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={option.id === "worktree" ? worktreeLeading : localLeading}
/>
);
}
const branchLeading = <IconBox>{branchIcon}</IconBox>;
const pullRequestLeading = <IconBox>{pullRequestIcon}</IconBox>;
export function RefOption({
option,
selected,
active,
onPress,
item,
disabled,
}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement {
const { t } = useTranslation();
const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined;
const trailingSlot = useMemo(
() =>
divergenceLabel ? <Text style={styles.divergenceLabel}>{divergenceLabel}</Text> : undefined,
[divergenceLabel],
);
if (!item) return <View key={option.id} />;
const isBranch = item.kind === "branch";
return (
<ComboboxItem
testID={
isBranch
? `new-workspace-ref-picker-branch-${item.name}`
: `new-workspace-ref-picker-pr-${item.item.number}`
}
label={pickerItemLabel(item)}
description={
!isBranch && item.item.baseRefName
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
: undefined
}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={isBranch ? branchLeading : pullRequestLeading}
trailingSlot={trailingSlot}
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
/>
);
}
const styles = StyleSheet.create((theme) => ({
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
divergenceLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontVariant: ["tabular-nums"],
},
}));
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
/**
* The new workspace screen's way into Import session: a ghost button at the top on compact
* The new workspace screen's way into Import session: a ghost button in the header on compact
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
*/
export function ImportSessionButton({
@@ -17,7 +17,7 @@ export function ImportSessionButton({
}) {
const { t } = useTranslation();
return (
<View style={compact ? styles.compact : styles.wide}>
<View style={compact ? null : styles.wide}>
<Button
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
variant="ghost"
@@ -34,9 +34,6 @@ export function ImportSessionButton({
}
const styles = StyleSheet.create((theme) => ({
compact: {
alignItems: "flex-start",
},
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
// border lines up with the composer's border.
wide: {
@@ -1,4 +1,9 @@
import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
@@ -203,6 +208,7 @@ import { PluginHeaderButtons } from "@/plugins";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -3658,9 +3664,42 @@ function WorkspaceScreenContent({
</View>
);
const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
const { revealDirectory } = useFileExplorerActions({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspaceRoot: workspaceDirectory,
});
const openMarkdownFile = useStableEvent((location: WorkspaceFileLocation) => {
handleOpenPreferredAssistantFile({ location, parentTabId: activeTabId });
});
const openMarkdownDirectory = useStableEvent((directoryPath: string) => {
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: persistenceKey,
checkout: activeExplorerCheckout,
view: "files",
});
});
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
workspaceRoot: workspaceDirectory ?? "",
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
}),
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
return () => markdownPathOpener.cancel();
}, [markdownPathOpener, isRouteFocused, activeTabId]);
const openMarkdownPath = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isRouteFocused) void markdownPathOpener.open(target, disposition);
},
);
const renderedWorkspaceScreen = (
<RenderProfile id="WorkspaceScreenContent">
@@ -3705,7 +3744,7 @@ function WorkspaceScreenContent({
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
onOpenWorkspaceFile={openMarkdownFile}
onOpenWorkspaceFile={openMarkdownPath}
toast={toast}
>
{renderedWorkspaceScreen}
+1 -1
View File
@@ -741,7 +741,7 @@ export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
const lightShadow = {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowColor: "rgba(0, 0, 0, 0.08)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
@@ -41,6 +41,93 @@ function rowWithText(
}
describe("native terminal selection", () => {
it.each([
{ startCol: 1, endCol: 8, expected: "한글 문장" },
{ startCol: 3, endCol: 8, expected: "글 문장" },
{ startCol: 1, endCol: 1, expected: "한" },
{ startCol: 0, endCol: 0, expected: "한" },
{ startCol: 0, endCol: 1, expected: "한" },
{ startCol: 4, endCol: 6, expected: " 문" },
])(
"copies complete wide characters for selected columns $startCol through $endCol",
async ({ startCol, endCol, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const selection = createTerminalSelectionModel();
selection.begin({
coordinate: { row: bounds.cursorRow, col: endCol },
bounds,
});
selection.update({
coordinate: { row: bounds.cursorRow, col: startCol },
bounds,
});
await copyTerminalSelection({
terminal,
selection: selection.getSnapshot().range,
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual([expected]);
} finally {
terminal.dispose();
}
},
);
it("copies Korean output without adding spaces for continuation cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
await copyTerminalSelection({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual(["한글 문장"]);
} finally {
terminal.dispose();
}
});
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장 | 성공");
const bounds = terminal.getBufferBounds();
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe("한글 문장 | 성공");
} finally {
terminal.dispose();
}
});
it("expands a long-press coordinate to the whole terminal word", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
await terminal.write("before PASEO_TARGET after\r\n");
@@ -324,10 +324,17 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s
const cells = window.rows[offset] ?? [];
const startsOnThisRow = row === selection.start.row;
const endsOnThisRow = row === selection.end.row;
const startCol = startsOnThisRow ? selection.start.col : 0;
let startCol = Math.max(0, startsOnThisRow ? selection.start.col : 0);
// Selecting either half highlights the whole wide character, so copy its leading cell too.
if (startCol > 0 && cells[startCol]?.width === 0) {
startCol -= 1;
}
const endCol = endsOnThisRow ? selection.end.col : cells.length - 1;
const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1);
const line = selectedCells.map((cell) => cell.char || " ").join("");
const selectedCells = cells.slice(startCol, Math.max(0, endCol) + 1);
const line = selectedCells
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join("");
if (offset > 0 && window.wrappedRows[offset] !== true) {
text += "\n";
}
+84
View File
@@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void {
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
describe("viewed timeline persistence", () => {
it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const partial = "Paragraph 1. Paragraph 2.";
const complete = `${partial} Paragraph 3. END`;
const owner = createOwner({
readTimeline: async () => ({
agentId: AGENT_ID,
items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
}),
commitTimeline: () => undefined,
});
try {
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(
() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID)
.status,
)
.toBe("painted");
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" },
} as AgentStreamEventPayload,
seq: 5,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
owner.applyTimelineResponse({
requestId: "resume",
agentId: AGENT_ID,
agent: null,
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
startCursor: { epoch: "epoch-1", seq: 5 },
endCursor: { epoch: "epoch-1", seq: 6 },
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: complete, messageId: "reply-1" },
timestamp: "2026-08-26T10:00:02.000Z",
seqStart: 2,
seqEnd: 6,
sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }],
collapsed: ["assistant_merge"],
},
],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
const replyTexts = () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.kind === "assistant_message")
.map((entry) => entry.text);
};
expect.soft(replyTexts()).toEqual([complete]);
owner.enqueueStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
seq: 7,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:03.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect.soft(replyTexts()).toEqual([complete]);
} finally {
owner.dispose();
}
});
it("shares an in-flight cache preparation with the viewed owner", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
@@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: {
};
const headMatch = findMatch(params.head);
const tailMatch = headMatch ? null : findMatch(params.tail);
const tailMatch = findMatch(params.tail);
const match = headMatch ?? tailMatch;
if (!match) {
return { tail: params.tail, head: params.head, reconciled: false };
@@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: {
};
if (headMatch) {
const tailCursor = tailMatch?.current.timelineCursor;
const replacesCachedPrefix =
tailMatch !== null &&
tailCursor?.epoch === params.epoch &&
unit.sourceSeqRanges.some(
(range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq,
);
return {
tail: params.tail,
// Live chunks can arrive before catch-up while their cached prefix is still painted.
// The projected reply owns both copies; keeping the prefix would duplicate it and
// cause completion to discard the updated head as an already-committed item.
tail: replacesCachedPrefix
? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)]
: params.tail,
head: replaceMatch(params.head, headMatch.index),
reconciled: true,
};
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/server": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+16
View File
@@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest";
import { createCli } from "./cli.js";
describe("canonical CLI surface", () => {
it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => {
const cli = createCli();
const owner =
path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!;
const send = owner.commands.find((command) => command.name() === "send")!;
send.action(() => {});
await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" });
expect(send.opts().behavior).toBe("interrupt");
await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" });
expect(send.opts().behavior).toBe("steer");
send.exitOverride().configureOutput({ writeErr: () => {} });
await expect(
send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }),
).rejects.toThrow();
});
it("offers daemon host selection as a global option", () => {
expect(createCli().helpInformation()).toContain("--host <host>");
});
+12 -2
View File
@@ -1,4 +1,5 @@
import { type Command } from "commander";
import { type Command, Option } from "commander";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import { collectMultiple } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
@@ -28,6 +29,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
};
export interface AgentSendOptions extends CommandOptions {
behavior?: ActiveTurnBehavior;
wait?: boolean;
image?: string[];
prompt?: string;
@@ -42,6 +44,12 @@ export function addSendOptions(cmd: Command): Command {
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
.addOption(
new Option(
"--behavior <behavior>",
"Active-turn behavior (agent default: steer; otherwise: interrupt)",
).choices(["interrupt", "steer"]),
)
.option("--no-wait", "Return immediately without waiting for completion");
}
@@ -191,10 +199,12 @@ export async function runSendCommand(
const images =
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
// Send the message
await client.sendAgentMessage(agentIdArg, promptInput, {
images,
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
sourceAgentId,
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
});
// If --no-wait, return immediately
+250 -33
View File
@@ -29,6 +29,116 @@ async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(
condition: () => Promise<boolean>,
description: string,
timeoutMs = 60_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) return;
await sleep(50);
}
throw new Error(`Timed out waiting for ${description}`);
}
interface ObservedRequest {
type: string;
message: Record<string, unknown>;
observedAt: number;
}
/**
* Observes real CLI requests while forwarding them to the isolated daemon.
*
* Requests are recorded where the CLI sends them, so a scenario can measure a wait from the
* request boundary instead of from process start.
*/
async function startRequestObserver(upstreamPort: number): Promise<{
port: number;
requests: ObservedRequest[];
waitForRequest: (type: string) => Promise<number>;
close: () => Promise<void>;
}> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const requests: ObservedRequest[] = [];
server.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${upstreamPort}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const frame = JSON.parse(data.toString()) as {
type?: string;
message?: { type?: string } & Record<string, unknown>;
};
if (frame.type === "session" && typeof frame.message?.type === "string") {
requests.push({
type: frame.message.type,
message: frame.message,
observedAt: Date.now(),
});
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
port: address.port,
requests,
waitForRequest: async (type: string) => {
await waitFor(
async () => requests.some((entry) => entry.type === type),
`${type} to reach the daemon`,
);
return requests.find((entry) => entry.type === type)!.observedAt;
},
close: async () => {
for (const socket of server.clients) socket.close();
await promisify(server.close.bind(server))();
},
};
}
/** Build command that blocks the plugin lifecycle queue until the test writes the gate file. */
const gateWaitBuild = [
'const fs = require("node:fs");',
'fs.writeFileSync(process.argv[1], "started");',
"const wait = () => {",
" if (fs.existsSync(process.argv[2])) return;",
" setTimeout(wait, 50);",
"};",
"wait();",
].join("\n");
/** Plugin whose every activation appends one line, so a test can count real reactivations. */
function activationCountingPlugin(logPath: string): string {
return [
'import { appendFileSync } from "node:fs";',
`const activationLog = ${JSON.stringify(logPath)};`,
"export default function contribute(plugin: unknown) {",
' appendFileSync(activationLog, "activate\\n");',
" void plugin;",
" return () => undefined;",
"}",
].join("\n");
}
async function countActivations(logPath: string): Promise<number> {
const content = await readFile(logPath, "utf8").catch(() => "");
return content.split("\n").filter(Boolean).length;
}
async function main(): Promise<void> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
@@ -48,31 +158,11 @@ async function main(): Promise<void> {
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
// Observe real CLI requests while forwarding them to the isolated daemon.
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const sources: string[] = [];
proxy.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const message = JSON.parse(data.toString());
if (
message.type === "session" &&
message.message.type === "plugin.source.install.request"
) {
sources.push(message.message.source);
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(proxy, "listening");
const address = proxy.address();
assert.ok(address && typeof address !== "string");
const observer = await startRequestObserver(context.port);
const installedSources = () =>
observer.requests
.filter((entry) => entry.type === "plugin.source.install.request")
.map((entry) => entry.message.source);
const sibling = path.join(context.workDir, "x");
const caller = path.join(context.workDir, "caller");
await mkdir(caller);
@@ -96,12 +186,12 @@ async function main(): Promise<void> {
["~", context.paseoHome, context.paseoHome, []],
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
] as const) {
sources.length = 0;
observer.requests.length = 0;
const result = await context.paseo(
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [expectedSource]);
assert.deepEqual(installedSources(), [expectedSource]);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
@@ -109,17 +199,16 @@ async function main(): Promise<void> {
}
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
sources.length = 0;
observer.requests.length = 0;
const missing = await context.paseo(
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [windowsSource]);
assert.deepEqual(installedSources(), [windowsSource]);
assert.equal(missing.exitCode, 1);
assert.match(missing.stderr, /Plugin directory does not exist/);
} finally {
for (const socket of proxy.clients) socket.close();
await promisify(proxy.close.bind(proxy))();
await observer.close();
}
const install = await context.paseo(["plugin", "install", directory, "--json"]);
@@ -242,6 +331,134 @@ async function main(): Promise<void> {
assert.equal(enable.exitCode, 0, enable.stderr);
assert.equal(JSON.parse(enable.stdout).status, "running");
// A reload that finishes after the client's former one-minute deadline must still report its
// real result. The wait is the daemon's own lifecycle queue, so it runs on real wall-clock time.
const gateDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-reload-e2e-"));
const buildStarted = path.join(gateDirectory, "build-started");
const buildGate = path.join(gateDirectory, "build-gate");
const activationLog = path.join(gateDirectory, "activations.log");
const targetDirectory = path.join(gateDirectory, "slow-reload-target");
const slowRepository = path.join(gateDirectory, "slow-build");
const reloadObserver = await startRequestObserver(context.port);
let slowInstall: ReturnType<typeof context.paseo> | undefined;
let slowReload: ReturnType<typeof context.paseo> | undefined;
try {
await mkdir(targetDirectory);
await writeFile(
path.join(targetDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "slow-reload-target",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(
path.join(targetDirectory, "index.server.ts"),
activationCountingPlugin(activationLog),
);
const targetInstall = await context.paseo(["plugin", "install", targetDirectory, "--json"]);
assert.equal(targetInstall.exitCode, 0, targetInstall.stderr);
assert.equal(await countActivations(activationLog), 1);
await mkdir(slowRepository);
await git(slowRepository, ["init", "-b", "main"]);
await git(slowRepository, ["config", "user.name", "Paseo Tests"]);
await git(slowRepository, ["config", "user.email", "paseo@example.test"]);
await writeFile(
path.join(slowRepository, "paseo-plugin.json"),
JSON.stringify({
id: "slow-build",
requirements: { paseo: `>=${resolveCliVersion()}` },
build: [[process.execPath, "-e", gateWaitBuild, buildStarted, buildGate]],
}),
);
await writeFile(path.join(slowRepository, "index.server.ts"), pluginSource);
await git(slowRepository, ["add", "-A"]);
await git(slowRepository, ["commit", "-m", "blocking build"]);
// This install holds the plugin lifecycle queue while its build waits for the gate.
slowInstall = context.paseo(
["plugin", "add", `git:${pathToFileURL(slowRepository).href}`, "--json"],
{ timeout: 180_000 },
);
// A failed launch must not arrive as an unhandled rejection: that ends this direct Node run
// before the finally block below releases the build gate. Its result is still asserted.
void slowInstall.catch(() => undefined);
await waitFor(
async () =>
readFile(buildStarted, "utf8").then(
() => true,
() => false,
),
"the blocking build to start",
);
slowReload = context.paseo(
[
"plugin",
"reload",
"slow-reload-target",
"--host",
`127.0.0.1:${reloadObserver.port}`,
"--json",
],
{ timeout: 180_000 },
);
// Same as the install above: a launch failure must not end the run before the cleanup.
void slowReload.catch(() => undefined);
const reloadRequestedAt = await reloadObserver.waitForRequest("plugin.reload.request");
// The interim listing is not the reload's outcome: it still reports the plugin running and
// no restart has happened while the reload waits behind the queue.
const duringReload = await context.paseo(["plugin", "ls", "slow-reload-target", "--json"]);
assert.equal(duringReload.exitCode, 0, duringReload.stderr);
assert.equal(JSON.parse(duringReload.stdout)[0].status, "running");
assert.equal(await countActivations(activationLog), 1);
const heldMs = Date.now() - reloadRequestedAt;
if (heldMs < 66_000) {
await sleep(66_000 - heldMs);
}
await writeFile(buildGate, "go");
const [buildResult, reloadResult] = await Promise.all([slowInstall, slowReload]);
assert.equal(buildResult.exitCode, 0, buildResult.stderr);
assert.equal(reloadResult.exitCode, 0, reloadResult.stderr);
assert.equal(JSON.parse(reloadResult.stdout).status, "running");
assert.equal(await countActivations(activationLog), 2);
// A plugin that cannot start still fails its reload with the daemon's error.
const brokenDirectory = path.join(gateDirectory, "broken-plugin");
await mkdir(brokenDirectory);
await writeFile(
path.join(brokenDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "broken-reload",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(path.join(brokenDirectory, "index.server.ts"), "export default broken !!!");
const brokenInstall = await context.paseo(["plugin", "install", brokenDirectory, "--json"]);
assert.equal(brokenInstall.exitCode, 1);
assert.match(brokenInstall.stderr, /broken-reload|broken|failed/i);
const brokenReload = await context.paseo(["plugin", "reload", "broken-reload", "--json"], {
timeout: 60_000,
});
assert.equal(brokenReload.exitCode, 1);
assert.match(brokenReload.stderr, /broken-reload|broken|failed/i);
for (const pluginId of ["slow-reload-target", "slow-build", "broken-reload"]) {
const removed = await context.paseo(["plugin", "remove", pluginId, "--json"]);
assert.equal(removed.exitCode, 0, removed.stderr);
}
} finally {
// Release the gate, then let the install and the reload settle before their files go: the
// build polls for the gate file, so cleanup must not delete it while the child still waits.
await writeFile(buildGate, "go").catch(() => undefined);
await Promise.all([slowInstall?.catch(() => undefined), slowReload?.catch(() => undefined)]);
await reloadObserver.close();
await rm(gateDirectory, { recursive: true, force: true });
}
const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]);
assert.equal(remove.exitCode, 0, remove.stderr);
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
+194
View File
@@ -1963,6 +1963,200 @@ test("gets a structured plugin log snapshot", async () => {
]);
});
test("keeps a plugin reload pending past the session RPC deadline", async () => {
useHeartbeatClock();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
let settled: "pending" | "resolved" | "rejected" = "pending";
void reload.then(
() => {
settled = "resolved";
return null;
},
() => {
settled = "rejected";
return null;
},
);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "plugin.reload.request",
requestId: expect.any(String),
pluginId: "example",
});
// A daemon that reloads for almost two minutes is still reloading, not a failed request.
await vi.advanceTimersByTimeAsync(114_034);
expect(settled).toBe("pending");
// Another plugin's reload finishing early must not settle this request.
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.reload.response",
payload: {
requestId: "req-other-plugin",
plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" },
},
}),
);
await vi.advanceTimersByTimeAsync(0);
expect(settled).toBe("pending");
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.reload.response",
payload: {
requestId: request.requestId,
plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" },
},
}),
);
await expect(reload).resolves.toEqual({
id: "example",
path: "/plugins/example",
enabled: true,
status: "running",
});
} finally {
vi.useRealTimers();
}
});
test("keeps enable and disable inside the session RPC deadline", async () => {
useHeartbeatClock();
try {
for (const action of ["enable", "disable"] as const) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const pending =
action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example");
let settled = false;
void pending.then(
() => {
settled = true;
return null;
},
() => {
settled = true;
return null;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: `plugin.${action}.request`,
requestId: expect.any(String),
pluginId: "example",
});
await vi.advanceTimersByTimeAsync(59_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)");
}
} finally {
vi.useRealTimers();
}
});
test("reports a plugin reload failure that arrives after the session RPC deadline", async () => {
useHeartbeatClock();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
const request = parseSentFrame(mock.sent[0]);
await vi.advanceTimersByTimeAsync(114_034);
mock.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: "plugin.reload.request",
error: "Request failed: Plugin failed to start: example",
code: "handler_error",
},
}),
);
await expect(reload).rejects.toThrow(
"Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error",
);
} finally {
vi.useRealTimers();
}
});
test("rejects a pending plugin reload when the connection closes", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" });
mock.triggerClose({ code: 1006, reason: "network lost" });
await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i);
// Reconnecting must not resend the lost reload: only the caller decides to reload again.
const reconnecting = client.connect();
mock.triggerOpen();
await reconnecting;
expect(mock.sent).toEqual([]);
});
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
useHeartbeatClock();
const logger = createMockLogger();
+4
View File
@@ -5692,6 +5692,10 @@ export class DaemonClient {
requestId,
message: { type: `plugin.${action}.request`, requestId, pluginId },
responseType: `plugin.${action}.response`,
// Reload tears the plugin down and starts it again before the daemon answers, and it queues
// behind other plugin lifecycle work. Wait for that answer instead of failing the reload at
// the request deadline while the daemon keeps loading it.
...(action === "reload" ? { timeout: 0 } : {}),
});
return payload.plugin;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.1",
"version": "0.12.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.12.0-beta.1",
"version": "0.12.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.12.0-beta.1",
"version": "0.12.0-beta.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/highlight": "0.12.0-beta.1",
"@getpaseo/plugin": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -402,6 +402,8 @@ export interface SetupFinishNotificationParams {
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
dispatchResult?: Promise<PromptDispatchDisposition | false>;
/** A replacement must observe its new start, not the displaced running snapshot. */
waitForTurnStart?: boolean;
logger: Logger;
@@ -466,10 +468,17 @@ interface NotifySafelyOptions {
permissionRequest?: AgentPermissionRequest;
}
// A caller waits on a child through one armed notification. Arming again, such as a
// follow-up prompt while the child still runs, replaces the earlier one so the child's
// next finish reaches the caller once.
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
// every earlier observation for the caller/child pair; a failure removes only itself.
interface ArmedFinishNotification {
stop(): void;
supersedeWhen(accepted: Promise<boolean>): void;
}
const armedFinishNotifications = new WeakMap<
AgentManager,
Map<string, Set<ArmedFinishNotification>>
>();
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
const {
@@ -491,15 +500,49 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
armedByManager.get(armedKey)?.();
armedByManager.set(armedKey, stop);
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
armedByManager.set(armedKey, registrations);
const previous = [...registrations];
let superseded = Promise.resolve(false);
let dispatchPending = Boolean(params.dispatchResult);
const registration: ArmedFinishNotification = {
stop,
supersedeWhen(accepted) {
superseded = superseded.then(
async (alreadySuperseded) => alreadySuperseded || (await accepted),
);
},
};
registrations.add(registration);
if (params.dispatchResult) {
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
void params.dispatchResult.then((accepted) => {
dispatchPending = false;
if (accepted) {
for (const observer of previous) observer.stop();
if (stopped || accepted === "turn_started") stop();
} else {
stop();
}
return undefined;
});
} else {
for (const observer of previous) {
observer.supersedeWhen(Promise.resolve(true));
observer.stop();
}
}
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe?.();
if (armedByManager.get(armedKey) === stop) {
armedByManager.delete(armedKey);
// Pending observations remain visible to overlapping sends until acknowledgement.
if (!dispatchPending) {
registrations.delete(registration);
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
armedByManager.delete(armedKey);
}
}
}
@@ -507,6 +550,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
if (params.dispatchResult) {
const result = await params.dispatchResult;
if (result === false || result === "turn_started") return;
}
if (await superseded) return;
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
@@ -1,3 +1,4 @@
import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js";
import { parseAgentMessage } from "./agent-messages/index.js";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -242,6 +243,7 @@ function buildAgentManagerSpies() {
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
@@ -3860,6 +3862,9 @@ const HELD_TURN_CAPABILITIES = {
} as const;
interface HeldTurnOptions {
finishDuringSteer?: boolean;
rejectSteer?: boolean;
steerResult?: "accepted" | "unavailable";
/** Run the whole turn before the turn-start request returns. */
finishTurnsDuringStart?: boolean;
}
@@ -3872,6 +3877,7 @@ class HeldTurnAgentSession implements AgentSession {
readonly capabilities = HELD_TURN_CAPABILITIES;
readonly id = randomUUID();
readonly prompts: string[] = [];
readonly steeredPrompts: AgentPromptInput[] = [];
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
private turnStartGate: Promise<void> | null = null;
@@ -3904,6 +3910,14 @@ class HeldTurnAgentSession implements AgentSession {
return prompt === "/side-command" ? { run: async () => {} } : null;
}
async steerActiveTurn(prompt: AgentPromptInput) {
const status = this.options.steerResult ?? "unavailable";
if (status === "accepted") this.steeredPrompts.push(prompt);
if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn();
if (this.options.rejectSteer) throw new Error("Steer rejected");
return { status };
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
await this.turnStartGate;
@@ -4050,6 +4064,295 @@ class HeldTurnAgentClient implements AgentClient {
}
describe("send_agent_prompt MCP tool", () => {
it.each([
{ agentScoped: true, behavior: undefined, available: true, steered: true },
{ agentScoped: true, behavior: "interrupt", available: true, steered: false },
{ agentScoped: true, behavior: "steer", available: true, steered: true },
{ agentScoped: false, behavior: undefined, available: true, steered: false },
{ agentScoped: false, behavior: "steer", available: true, steered: true },
{ agentScoped: true, behavior: undefined, available: false, steered: false },
] as const)(
"dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available",
async ({ agentScoped, behavior, available, steered }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: available ? "accepted" : "unavailable",
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: agentScoped ? parent.id : undefined,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Original work",
notifyOnFinish: false,
});
const originalTurn = manager.getAgent(child.id)!.activeTurnId;
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "New message",
notifyOnFinish: false,
...(behavior ? { activeTurnBehavior: behavior } : {}),
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: steered ? "steered" : "turn_started",
});
expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2);
expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0);
expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered);
} finally {
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([
{ rejected: false, priorNotification: false },
{ rejected: true, priorNotification: false },
{ rejected: false, priorNotification: true },
])(
"buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)",
async ({ rejected, priorNotification }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer: true,
rejectSteer: rejected,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: priorNotification,
});
const sending = send({
agentId: child.id,
prompt: "New message",
notifyOnFinish: true,
});
if (rejected) {
expect(await sending).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
});
// Flush asynchronous notification work before checking that a rejected
// dispatch did not notify. Then prove a later successful send still can.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(0);
await send({
agentId: child.id,
prompt: "Retry after rejection",
});
client.sessions[1]!.finishTurn();
} else {
const response = await sending;
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "steered",
});
}
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([false, true])(
"preserves the original notification after rejection (completion during send: %s)",
async (finishDuringSteer) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer,
rejectSteer: true,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: true,
});
const sending = send({
agentId: child.id,
prompt: "New message",
notifyOnFinish: true,
});
expect(await sending).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
});
if (!finishDuringSteer) {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(0);
client.sessions[1]!.finishTurn();
}
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([
[false, true],
[true, false],
[false, false],
[true, true],
])(
"overlapping follow-ups notify once (first accepted: %s, second accepted: %s)",
async (firstAccepted, secondAccepted) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer: false,
rejectSteer: true,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: true,
});
let firstResolve!: (result: PromptDispatchDisposition | false) => void;
let secondResolve!: (result: PromptDispatchDisposition | false) => void;
const first = new Promise<PromptDispatchDisposition | false>((resolve) => {
firstResolve = resolve;
});
const second = new Promise<PromptDispatchDisposition | false>((resolve) => {
secondResolve = resolve;
});
for (const dispatchResult of [first, second]) {
setupFinishNotification({
agentManager: manager,
agentStorage: storage,
childAgentId: child.id,
callerAgentId: parent.id,
dispatchResult,
logger,
});
}
firstResolve(firstAccepted ? "steered" : false);
await first;
secondResolve(secondAccepted ? "steered" : false);
await second;
client.sessions[1]!.finishTurn();
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -4100,7 +4403,7 @@ describe("send_agent_prompt MCP tool", () => {
const response = await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.subscribe).toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
@@ -4270,7 +4573,7 @@ describe("send_agent_prompt MCP tool", () => {
},
);
it.each(["out_of_band", "startup_failure"] as const)(
it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)(
"preserves %s dispatch notification semantics",
async (scenario) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
@@ -4306,10 +4609,11 @@ describe("send_agent_prompt MCP tool", () => {
prompt: "Ongoing task",
notifyOnFinish: false,
});
if (scenario === "out_of_band") {
if (scenario !== "startup_failure") {
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "/side-command",
activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer",
});
expect(response.structuredContent).toMatchObject({
success: true,
@@ -1617,7 +1617,12 @@ describe("ACPAgentSession Zed parity", () => {
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
// Allow All is agent mode with permissions pre-granted, so it has to leave
// whatever mode the session is in, not just flip the flag.
expect(setSessionMode).toHaveBeenCalledWith({
sessionId: "session-1",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
});
@@ -1648,7 +1653,12 @@ describe("ACPAgentSession Zed parity", () => {
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
// Allow All is agent mode with permissions pre-granted, so it has to leave
// whatever mode the session is in, not just flip the flag.
expect(setSessionMode).toHaveBeenCalledWith({
sessionId: "session-1",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
@@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns(
seq: rows.length + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
item: event.item,
...(currentTurnId ? { turnId: currentTurnId } : {}),
...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}),
});
}
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
@@ -469,122 +469,145 @@ describe("background Claude subagents", () => {
});
});
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
test.each(["before", "after"])(
"keeps one logged Task card when child tools start %s the parent finishes",
async (timing) => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
},
},
},
],
],
},
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
...(timing === "after"
? [
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
type: "system",
subtype: "init",
session_id: "bg-session",
permissionMode: "default",
},
]
: []),
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
...(timing === "before"
? [
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
type: "system",
subtype: "init",
session_id: "bg-session",
permissionMode: "default",
},
]
: []),
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
log: "[Bash] ls -1",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
});
},
);
test.each([
{
@@ -575,6 +575,36 @@ describe("ClaudeAgentClient binary resolution", () => {
await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/);
});
test("rejects a wrapper command's own version output instead of reading it as Claude Code's", async () => {
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
const wrapperPath = path.join(wrapperDir, "wrapper");
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
try {
await expect(
resolveClaudeCodeVersion({
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
}),
).rejects.toThrow("Unable to parse Claude Code version from --version output");
} finally {
await fs.rm(wrapperDir, { recursive: true, force: true });
}
});
test("resolves the version when the command forwards the Claude Code banner", async () => {
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
const wrapperPath = path.join(wrapperDir, "wrapper");
await fs.writeFile(wrapperPath, 'console.log("2.1.302 (Claude Code)");\n');
try {
await expect(
resolveClaudeCodeVersion({
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
}),
).resolves.toBe("2.1.302");
} finally {
await fs.rm(wrapperDir, { recursive: true, force: true });
}
});
test("loads user, project, and local Claude settings", async () => {
const queryReturn = vi.fn();
queryReturn.mockResolvedValue(undefined);
@@ -1839,7 +1839,13 @@ export async function resolveClaudeCodeVersion(
timeout: 5_000,
signal,
});
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
// A wrapper command (the `command` provider setting) prints its own version here
// instead of Claude Code's. Only trust output that identifies itself as Claude Code,
// so a wrapper's version can't mis-gate models behind a minimumClaudeCodeVersion.
const rawVersionOutput = `${stdout}\n${stderr}`;
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
? parseClaudeCodeVersion(rawVersionOutput)
: null;
if (!version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
@@ -2244,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
this.settledSubagentToolCalls.get(toolUseId)?.input ??
null,
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
// identity and status from frames for them. Detecting the capability beats comparing version
@@ -2252,13 +2258,11 @@ class ClaudeAgentSession implements AgentSession {
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// A background child streams its frames after Claude has already answered the call, so its
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
// Late card updates retain the turn that answered the call.
needsSyntheticParentToolCard: (toolUseId) =>
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
(this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -4484,7 +4488,12 @@ class ClaudeAgentSession implements AgentSession {
),
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
const routedId = canonicalSubagentId ?? parentToolUseId;
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)];
const settled = this.settledSubagentToolCalls.get(routedId);
const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => {
if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId;
return event;
});
return [...runtimeEvents, ...sidechainEvents];
}
/**
@@ -5139,7 +5148,8 @@ class ClaudeAgentSession implements AgentSession {
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const turnId =
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
@@ -5615,17 +5625,12 @@ class ClaudeAgentSession implements AgentSession {
});
}
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
const settled = this.settledSubagentToolCalls.get(subagentId);
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
return settled && settled.turnId === currentTurnId ? settled : undefined;
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
this.sidechainTracker.delete(observation.id);
}
}
}
@@ -559,6 +559,33 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
expect(models.map((model) => model.id)).not.toContain("claude-opus-5");
});
it("keeps version-gated models when the probe command cannot identify Claude Code", async () => {
const configDir = await createClaudeConfigDir({});
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-wrapper-"));
const wrapperPath = path.join(wrapperDir, "wrapper");
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
try {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: {
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
env: { CLAUDE_CONFIG_DIR: configDir },
},
discoverModels: async () => [],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.map((model) => model.id)).toContain("claude-opus-5-5");
} finally {
await fs.rm(wrapperDir, { recursive: true, force: true });
}
});
});
describe("normalizeClaudeRuntimeModelId", () => {
@@ -4,6 +4,40 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("retains a background child's actions across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
});
const action = (id: string, command: string) =>
({
type: "assistant",
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
tracker.handleMessage(action("ls", "ls -1"), "task-1");
expect(tracker.finishAll("completed")).toEqual([]);
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
}),
}),
);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
});
it("uses Claude's native agent name for the provider subagent title", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({
@@ -199,7 +199,7 @@ export class ClaudeSidechainTracker {
finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
if (this.isDescriptorOwnedElsewhere()) {
this.activeSidechains.clear();
// The task protocol owns child completion; the parent finishing does not end its children.
return events;
}
for (const [id, state] of this.activeSidechains) {
@@ -209,6 +209,10 @@ export async function writeCopilotProviderMode(
if (!requestsAllowAll) {
return { handled: false };
}
await context.connection.setSessionMode({
sessionId: context.sessionId,
modeId: COPILOT_AGENT_MODE_ID,
});
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
@@ -0,0 +1,82 @@
import { beforeAll, describe, expect, test } from "vitest";
import { spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import { isCommandAvailable } from "../../../executable-resolution/executable-resolution.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { resolveACPModeSelection } from "./acp-agent.js";
import {
COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
const AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
const PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
describe("Copilot ACP provider (real) Allow All mode", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("copilot");
});
test("selecting Allow All from Plan leaves the real provider in Agent mode", async (context) => {
if (!canRun) {
context.skip();
}
const cwd = await mkdtemp(join(tmpdir(), "copilot-allow-all-real-"));
const child = spawn("copilot", ["--acp"], { cwd, stdio: ["pipe", "pipe", "ignore"] });
const connection = new ClientSideConnection(
() => ({
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
}),
ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)),
);
try {
await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} });
const session = await connection.newSession({ cwd, mcpServers: [] });
const plan = await connection.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: PLAN_MODE_ID,
});
expect(plan.configOptions).toContainEqual(
expect.objectContaining({ id: "mode", currentValue: PLAN_MODE_ID }),
);
// This is the same provider writer called by ACPAgentSession. Assert its
// untransformed ACP response, since Paseo's displayed mode hides this bug.
const result = await writeCopilotProviderMode({
connection,
sessionId: session.sessionId,
requestedModeId: COPILOT_ALLOW_ALL_MODE_ID,
currentModeId: PLAN_MODE_ID,
selection: resolveACPModeSelection({
modeId: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: COPILOT_MODES,
configOptions: plan.configOptions,
}),
configOptions: plan.configOptions,
logger: createTestLogger(),
});
expect(result.configOptions).toContainEqual(
expect.objectContaining({ id: "mode", currentValue: AGENT_MODE_ID }),
);
expect(result.configOptions).toContainEqual(
expect.objectContaining({ id: "allow_all", currentValue: "on" }),
);
} finally {
await terminateWithTreeKill(child, { gracefulTimeoutMs: 1000, forceTimeoutMs: 1000 });
await rm(cwd, { recursive: true, force: true });
}
}, 60_000);
});
@@ -6,7 +6,7 @@ import type { Logger } from "pino";
import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentManager } from "../agent-manager.js";
import { AgentProfileSchema } from "@getpaseo/protocol/messages";
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import {
AgentFeatureSchema,
@@ -1209,6 +1209,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
.object(legacyTopLevelCreateAgentInputSchema)
.strict();
const commonSendAgentPromptInputSchema = {
activeTurnBehavior: ActiveTurnBehaviorSchema.optional().describe(
'How to send into a running turn: "steer" joins it, falling back to interrupt when unavailable; "interrupt" replaces it. Defaults to steer for agent callers and interrupt otherwise.',
),
agentId: z.string(),
prompt: z.string(),
sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."),
@@ -1955,28 +1958,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
inputSchema: sendAgentPromptInputSchema,
outputSchema: {
success: z.boolean(),
disposition: z.enum(["turn_started", "out_of_band"]).optional(),
disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(),
status: AgentStatusEnum,
guidance: z.string().optional(),
},
},
async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => {
async ({
agentId,
prompt,
sessionMode,
notifyOnFinish = Boolean(callerAgentId),
activeTurnBehavior = callerAgentId ? "steer" : "interrupt",
}) => {
let stopNotification: (() => void) | undefined;
function armFinishNotification(disposition: PromptDispatchDisposition): void {
if (!callerAgentId || !notifyOnFinish) {
return;
}
let acceptDispatch!: (result: PromptDispatchDisposition | false) => void;
const dispatchResult = new Promise<PromptDispatchDisposition | false>((resolve) => {
acceptDispatch = resolve;
});
const notifying = Boolean(callerAgentId && notifyOnFinish);
if (callerAgentId && notifyOnFinish) {
stopNotification = setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
waitForTurnStart: disposition === "turn_started",
waitForTurnStart: false,
dispatchResult,
logger: childLogger,
});
}
function confirmDispatch(disposition: PromptDispatchDisposition): void {
// A new turn must ignore outcomes from the displaced turn. Its iterator
// has not started yet, so rearm here; steering keeps the early observer.
if (disposition !== "turn_started") {
acceptDispatch(disposition);
return;
}
acceptDispatch(disposition);
if (!callerAgentId || !notifyOnFinish) return;
stopNotification?.();
stopNotification = setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
waitForTurnStart: true,
logger: childLogger,
});
}
const notifying = Boolean(callerAgentId && notifyOnFinish);
const { disposition } = await sendPromptToAgent({
agentManager,
agentStorage,
@@ -1984,9 +2014,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
prompt,
source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined,
sessionMode,
onDispatch: armFinishNotification,
activeTurnBehavior,
onDispatch: confirmDispatch,
logger: childLogger,
}).catch((error) => {
acceptDispatch(false);
stopNotification?.();
if (error instanceof AgentNotFoundError) {
throw new PaseoToolInputError(error.message);
@@ -0,0 +1,105 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
test("background cards retain child actions after the parent finishes (#6253)", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
logger,
agentClients: { claude: new ClaudeAgentClient({ logger }) },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd,
title: "Background action log reproduction",
});
await client.sendMessage(
agent.id,
'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.',
);
await client.waitForFinish(agent.id, 180_000);
const atFinish = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
await expect
.poll(
async () => {
const { subagents } = await client.listProviderSubagents(agent.id);
return subagents.length === 2 && subagents.every((child) => child.status === "completed");
},
{ timeout: 180_000, interval: 1000 },
)
.toBe(true);
const { subagents } = await client.listProviderSubagents(agent.id);
const children = await Promise.all(
subagents.map(async (child) => ({
description: child.description,
timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, {
direction: "tail",
limit: 0,
}),
})),
);
const final = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
"logs",
agent.id,
],
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
);
expect(stdout).toContain("[Bash] ls -1");
expect(stdout).toContain("[Bash] pwd");
expect(
atFinish.entries.filter(
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
),
).toHaveLength(2);
const cards = final.entries
.map((row) => row.item)
.filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent");
expect(cards).toHaveLength(2);
for (const child of children) {
expect(
child.timeline.rows.some(
(row) => row.item.type === "tool_call" && row.item.name === "Bash",
),
).toBe(true);
}
for (const card of cards) {
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
@@ -1,5 +1,5 @@
import { createHook } from "node:async_hooks";
import { execFileSync } from "node:child_process";
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
@@ -1066,6 +1066,16 @@ describe("relative typed-entry configuration", () => {
});
});
interface SearchProcessExit {
code: number | null;
signal: string | null;
}
interface SearchProcess {
child: ChildProcess;
exited: Promise<SearchProcessExit>;
}
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
// read and is what made large home directories exceed the client request timeout.
@@ -1073,6 +1083,7 @@ describe("home-tree scan cost", () => {
const DEPTH = 8;
const CHAINS = 40;
let scanRoot: string;
let searchProcess: SearchProcess | null = null;
beforeEach(() => {
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
@@ -1083,8 +1094,16 @@ describe("home-tree scan cost", () => {
}
});
afterEach(() => {
rmSync(scanRoot, { recursive: true, force: true });
afterEach(async () => {
try {
if (searchProcess) {
searchProcess.child.kill();
await searchProcess.exited;
}
} finally {
searchProcess = null;
rmSync(scanRoot, { recursive: true, force: true });
}
});
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
@@ -1102,6 +1121,78 @@ describe("home-tree scan cost", () => {
return stopPathContainmentMetrics();
}
it("finishes overlapping broad-tree searches within a bounded heap", async () => {
const broadRoot = path.join(scanRoot, "broad");
mkdirSync(broadRoot);
for (let branch = 0; branch < 30; branch += 1) {
const branchRoot = path.join(broadRoot, `branch-${branch}`);
mkdirSync(branchRoot);
for (let group = 0; group < 30; group += 1) {
const groupRoot = path.join(branchRoot, `group-${group}`);
mkdirSync(groupRoot);
for (let leaf = 0; leaf < 30; leaf += 1) {
mkdirSync(path.join(groupRoot, `leaf-${leaf}`));
}
}
}
// A smaller real tree and explicit heap budget keep the million-directory repro cheap in CI.
const moduleUrl = new URL("./directory-suggestions.ts", import.meta.url).href;
const source = `
const { searchDirectoryEntries } = await import(${JSON.stringify(moduleUrl)});
const results = await Promise.all(Array.from({ length: 14 }, (_, index) =>
searchDirectoryEntries({
root: ${JSON.stringify(broadRoot)},
query: "z".repeat(index + 1),
pathFormat: "absolute",
includeFiles: false,
includeDirectories: true,
pathQueryPolicy: "rooted",
rootAliases: ["~"],
blankQueryBehavior: "none",
confidentResultScanThreshold: 5000,
maxEntriesScanned: 2000,
limit: 30,
})
));
process.stdout.write(JSON.stringify(results));
`;
const child = spawn(
process.execPath,
[
"--max-old-space-size=128",
"--conditions=source",
"--import",
"tsx",
"--input-type=module",
"--eval",
source,
],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 },
);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const exited = new Promise<SearchProcessExit>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal }));
});
searchProcess = { child, exited };
const exit = await exited;
expect({ ...exit, outOfMemory: stderr.includes("heap out of memory") }).toEqual({
code: 0,
signal: null,
outOfMemory: false,
});
expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => []));
});
it("derives no containment for a tree that cannot leave the root", async () => {
const checks = await countContainmentChecks(scanWholeTree);
@@ -63,6 +63,13 @@ interface WalkStart {
resolvedPath: string;
}
interface WalkChildrenOptions {
parent: WalkStart;
depth: number;
input: SearchInput;
visited: Set<string>;
}
interface TraversedEntry extends ChildEntry {
visiblePath: string;
depth: number;
@@ -318,17 +325,18 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
const discoveryInput = isInsideExcludedPath(parent, input)
? { ...input, excludedDiscoveryPaths: new Set<string>() }
: input;
const entries = await readChildren(parent);
return entries.flatMap((entry) => {
const ranked: RankedEntry[] = [];
for await (const entry of readChildren(parent)) {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
return [];
continue;
const candidate: TraversedEntry = {
...entry,
visiblePath: path.join(visibleParent, entry.name),
depth: 1,
};
return shouldSuggest(candidate, input) ? [rank(candidate, input)] : [];
});
if (shouldSuggest(candidate, input)) ranked.push(rank(candidate, input));
}
return ranked;
}
async function searchTree(
@@ -336,21 +344,8 @@ async function searchTree(
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
staysInsideRoot(entry, input.root),
);
const visited = new Set<string>([start.resolvedPath]);
const branches = roots.flatMap((entry) =>
shouldDiscover(entry, input)
? [
walkBranch(
{ ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 },
input,
visited,
),
]
: [],
);
const branches = walkChildren({ parent: start, depth: 1, input, visited });
const ranked: RankedEntry[] = [];
let scanned = 0;
const threshold = input.confidentResultScanThreshold;
@@ -381,38 +376,39 @@ async function* walkBranch(
)
return;
visited.add(entry.resolvedPath);
const children = (await readChildren(entry.resolvedPath)).filter((child) =>
staysInsideRoot(child, input.root),
);
const branches = children.flatMap((child) =>
shouldDiscover(child, input)
? [
walkBranch(
{
...child,
visiblePath: path.join(entry.visiblePath, child.name),
depth: entry.depth + 1,
},
input,
visited,
),
]
: [],
);
yield* roundRobin(branches);
yield* roundRobin(walkChildren({ parent: entry, depth: entry.depth + 1, input, visited }));
}
async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerator<T> {
let active = branches;
while (active.length) {
// Create a child's path and iterator only when its branch gets a turn. Eagerly constructing every
// sibling's subtree retained hundreds of thousands of unvisited children beyond the scan budget.
async function* walkChildren({
parent,
depth,
input,
visited,
}: WalkChildrenOptions): AsyncGenerator<AsyncGenerator<TraversedEntry>> {
for await (const child of readChildren(parent.resolvedPath)) {
if (!staysInsideRoot(child, input.root) || !shouldDiscover(child, input)) continue;
yield walkBranch(
{ ...child, visiblePath: path.join(parent.visiblePath, child.name), depth },
input,
visited,
);
}
}
async function* roundRobin<T>(branches: AsyncIterable<AsyncGenerator<T>>): AsyncGenerator<T> {
let active: AsyncIterable<AsyncGenerator<T>> | Array<AsyncGenerator<T>> = branches;
while (true) {
const nextRound: Array<AsyncGenerator<T>> = [];
for (const branch of active) {
for await (const branch of active) {
const next = await branch.next();
if (!next.done) {
nextRound.push(branch);
yield next.value;
}
}
if (!nextRound.length) return;
active = nextRound;
}
}
@@ -742,9 +738,9 @@ async function resolveDirectory(inputPath: string): Promise<string | null> {
return info?.isDirectory() ? resolved : null;
}
async function readChildren(directory: string): Promise<ChildEntry[]> {
async function* readChildren(directory: string): AsyncGenerator<ChildEntry> {
const directoryInfo = await stat(directory).catch(() => null);
if (!directoryInfo?.isDirectory()) return [];
if (!directoryInfo?.isDirectory()) return;
const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA
? directoryListCache.get(directory)
@@ -774,9 +770,10 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
}
}
return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry))))
.filter((entry): entry is ChildEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
for (const rawEntry of rawEntries) {
const entry = await resolveChild(directory, rawEntry);
if (entry) yield entry;
}
}
async function loadGitIgnoredPaths(root: string): Promise<Set<string>> {
-3
View File
@@ -19,9 +19,6 @@ export default defineConfig({
setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")],
pool: "forks",
fileParallelism: false,
// Even two concurrent Git-heavy suites can exhaust Windows test deadlines.
// Run files serially so their subprocesses do not compete across suites.
maxWorkers: process.platform === "win32" ? 1 : undefined,
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"],
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.2",
"private": true,
"type": "module",
"scripts": {
+11 -1
View File
@@ -244,6 +244,13 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
`paseo plugin reload <id>` reports the result of the reload itself: it waits while the daemon stops
and starts the plugin, however long that takes, and it queues behind the daemon's other plugin work.
Cancelling the command or losing the connection does not stop the daemon's work, and no listing or
log entry settles what a cancelled reload did — the [Plugin reference](/docs/plugins/reference)
covers those interim states. Running the reload again is what reports the result: it waits behind the
outstanding one, then stops and starts the plugin once more.
Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub
source, or `./plugin` for a local directory. Append `:<directory>` for a plugin in a monorepo.
@@ -282,9 +289,12 @@ Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab](
```bash
paseo send <id> "now run the tests"
paseo send <id> --image screenshot.png "what's wrong here?"
paseo send <id> --no-wait "queue this task"
paseo send <id> --no-wait "run this task"
paseo send <id> --behavior interrupt "stop and do this instead"
```
Agent-originated sends (`PASEO_AGENT_ID` set) default to steering the running turn. Other sends default to interrupting it. Use `--behavior steer` or `--behavior interrupt` to override the default. Steering uses the same fallback as the client: if steering is unavailable, the running turn is interrupted and replaced.
## Viewing logs
```bash
+1 -1
View File
@@ -51,7 +51,7 @@ Agents can prompt each other by **agent ID**, the identifier Paseo uses to addre
> Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.”
The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too.
The receiving agent gets the prompt in its existing conversation. Agent-originated sends default to steering a running turn, with interruption fallback when steering is unavailable. Set MCP `activeTurnBehavior` or CLI `--behavior` to `steer` or `interrupt` to override that default. To have it send a separate message back, give it the sender's agent ID too.
Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send <id>`](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine).
+13
View File
@@ -1318,6 +1318,13 @@ the source row, so updates to one streaming item do not remount its renderer. Us
`useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in
assistant rows.
Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also
matches the original message. The host searches the original prompt text or the assistant reply
with Markdown syntax removed, then the app counts matches in the text your renderer draws.
Text added only by your renderer is not independently indexed. Matches stay within a rendered
block, and find skips buttons, icons, and anything with `aria-hidden="true"`. Find only runs on
the web and desktop apps.
### Append a timeline row from the daemon
A server handler can add a plugin-owned row to canonical history:
@@ -2269,6 +2276,12 @@ remove Command Center items and clear the installation's query state. An already
remains as unavailable until its matching contribution returns or the user closes it. Panel render
failures stay inside the plugin error boundary.
Reload queues behind the daemon's other plugin work, such as a running installation or preparation
command, and the daemon answers when the plugin is running again or has failed. Nothing reads the
plugin's status as the reload's outcome: while the reload is queued, `plugin ls`, Settings, and the
logs show the plugin's previous state, and while the daemon stops and starts it, a status read
reports `failed` because the plugin is absent from the runtime catalog until its start completes.
## Plugin sources
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
+1 -1
View File
@@ -63,7 +63,7 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.