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
paseo-bot[bot]andFelipe Fidelix 30799fda19 Return actionable errors to OpenCode agents for invalid tool calls (#7183)
* fix(server): return actionable OpenCode bridge errors

* fix(server): surface OpenCode tool input errors

* fix(server): surface OpenCode v2 tool errors

* test(server): cover OpenCode v2 bridge errors

* fix(server): classify missing schedules as tool input

* fix(server): surface schedule state conflicts

* fix(server): validate schedule tool inputs

* fix(server): keep tool contracts while reporting OpenCode input errors

Preserve the removal of create_agent.background and keep legacy schedule fields hidden. Verify client errors through the real bridge, catalog, and schedule service rather than mocked schedule failures.

Co-authored-by: Felipe Fidelix <felipe@fdx.is>

* fix(server): distinguish caller errors from stored data failures

Tag validation failures where tool arguments are parsed, so schema errors from persisted state remain internal failures. Preserve the missing-agent error at its producer and translate it for prompt delivery. Real HTTP regressions fail for both misclassifications before this change.

* fix(server): preserve messages for unclassified tool failures

Keep existing error messages on HTTP 500 responses so agents can correct failed tool calls. Exercise missing workspaces through the real script service and bridge route.

* fix(server): preserve clearing schedule names with blank strings

---------

Co-authored-by: Felipe Fidelix <felipe@fdx.is>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 07:57:22 +02:00
Mohamed Boudra 6e1ec21c6a Keep Android dictation running through audio interruptions (#7200)
* fix(android): preserve dictation across audio interruptions

Playback focus loss does not mean microphone capture is unavailable. Keep dictation alive across media takeovers and wait for Android input silencing to clear, while preserving conversation interruption protection.

* test(android): verify dictation clears its selected audio route
2026-10-17 12:53:06 +07:00
paseo-bot[bot]andboeto f472c9d1f4 Correct workspace source parameters in the Paseo skill (#7188)
* docs(skills): align workspace source parameters

* docs(skills): clarify workspace source inputs

* test(skills): check workspace guidance against MCP inputs

Keep the bundled workspace guidance aligned with the tool schema agents receive, so removed source parameters do not remain in the instructions.

Co-authored-by: boeto <bo@eto.me>

---------

Co-authored-by: boeto <bo@eto.me>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 06:47:31 +02:00
Mohamed Boudra ec9817ed27 fix(app): make home actions reachable on short screens (#7167)
* fix(app): make home actions reachable on short screens

Keep the home cards and community links in one scrollable flow so overflow stays reachable and the footer cannot overlap the cards.

* fix(app): keep home scrolling clear of window drag regions
2026-10-17 11:29:58 +07:00
Mohamed Boudra 8555e8abd9 fix(release): distribute stable iOS builds to TestFlight testers (#7189)
Replace the upload-only submission with the existing TestFlight distribution job shape so external testers receive stable releases. Keep App Store review targeting the same version and build without a second binary upload.
2026-10-17 11:02:23 +07:00
paseo-bot[bot] 88a7aec1b8 fix(claude): keep active turns running through settings changes (#7179)
A pending thinking change retired the live query when mode, model, or command controls requested it. Reuse the query owned by the current turn and apply pending launch settings after the turn finishes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 03:54:00 +02:00
paseo-bot[bot]andHung Le cd77ecd837 Start idle OMP agents when they receive follow-ups (#7169)
* fix(server): run slash commands sent by another agent out of band again

* fix(omp): send /steer and /follow-up as prompts so an idle agent starts a run

* test(omp): verify idle follow-ups and parent notifications

Cover real daemon and MCP delivery, and keep the fake runtime tests independent of an installed OMP executable.

Co-authored-by: Hung Le <22406703+nhthung@users.noreply.github.com>

* fix(omp): report idle follow-up admission failures

Use the existing prompt lifecycle for an idle command so acknowledgements and late rejections settle the child and notify its caller. Keep busy commands on the streaming prompt path, and accept one runtime prompt input object.

---------

Co-authored-by: Hung Le <22406703+nhthung@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 01:10:35 +02:00
paseo-bot[bot] 30122b1ae1 Show a background Claude subagent's action log on its card (#6242)
* fix(claude): show a background subagent's action log on its card

Claude Code answers a background Agent call at launch and streams the
child's frames afterwards. Since #6067 the sidechain tracker stopped
emitting the parent card once the call had its result, so the card's
log stayed empty. The tracker now rebuilds the card from the settled
call, keeping its status, output, and label, and adds the child's
action log.

Closes #6157

* fix(claude): release a settled subagent call when the child finishes

* fix(claude): keep a background subagent's card in the turn that settled it

A background child often keeps streaming after the parent's turn ends. Its
frames then rebuilt the settled card in the next turn with an empty log, and
the projection showed it as a second card for the same call. The settled call
now rebuilds the card only within the turn that answered it.

* test(claude): model the turn boundary in background card coverage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-16 23:53:46 +02:00
xzthemeofsss d152f1529f fix(app): recover failed question responses (#7098)
* test: reproduce failed question response recovery

* fix: show question response failures and allow retry
2026-10-16 22:12:05 +02:00
paseo-ai[bot] 05b3745dcc fix: update lockfile signatures and Nix hash [skip ci] 2026-10-16 19:10:33 +00:00
Mohamed Boudra e2f4948f92 chore(release): cut 0.12.0-beta.1 2026-10-16 21:00:46 +02:00
Mohamed Boudra b1256a4484 docs(changelog): 0.12.0-beta.1 2026-10-16 20:56:36 +02:00
paseo-bot[bot]andNoah 90fcf51d76 Render the Antigravity icon for terminal profiles (#6938)
* fix(app): render terminal-profile icons instead of the generic Bot

* test(app): verify terminal profile artwork in the launch menu

Exercise the rendered SVG, dimensions, theme color, catalog artwork and fallback through the real browser app instead of inspecting hook results in JSDOM. The browser regression fails on main before the lookup fix.

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-16 19:56:28 +02:00
Mohamed Boudra a1c6e8dfaf fix(app): keep iOS fullscreen composer above the keyboard (#7170)
Keep keyboard translation attached to the native animation graph when fullscreen resizes the dock. Apply the enabled mode on the native animation frame so Fabric does not restore the previous transform during layout.
2026-10-17 00:38:15 +07:00
babo-jeongukandClaude Opus 5.5 df82edd218 Split the inbound WebSocket validator per message type (#6847)
* Split the inbound WebSocket validator per message type

zod-aot compiled WSOutboundMessageSchema into one 11 MB function. Every
inbound frame, heartbeats included, ran through it, so V8 hosts (daemon
clients, plugin hosts, the CLI, Electron) eventually Maglev-compiled the
whole function: about 800 MB of compiler memory and multi-second stalls,
again after each deoptimization.

Generate a tag-only envelope validator plus one validator per session
message, and dispatch on the message type. Issue paths, defaults and
passthrough behave as before.

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

* Use forward slashes in generated validator imports

path.relative() returns backslash paths on Windows, which break the
import specifiers written into the generated files.

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

* Declare the session export helper as a function

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:08:03 +02:00
Mohamed Boudra ae0b2588bf fix(app): move Show background to the end of workspace display menu 2026-10-16 18:36:45 +02:00
Mohamed BoudraandKassam Khoja 98559bf729 fix(app): release iOS sidebar rows after an interrupted drag (#7159)
An iOS pan waiting for manual activation can deliver finger-up without finalizing, leaving the row active and the outer scroller locked. Fail a pending pan when its touch stream ends so the existing finalization path cancels the drag. Track activation immediately so an active drag keeps its normal completion path.

Based on the touch-release diagnosis in #5586. The native regression fails before the fix and passes on the Namespace iOS simulator.

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>
2026-10-16 22:30:27 +07:00
Mohamed Boudra 78c7a9ce3a Merge remote-tracking branch 'origin/main' 2026-10-16 17:24:06 +02:00
Mohamed Boudra a8deb51086 fix(server): keep provider images separate from adjacent prose
Generated image Markdown was concatenated into the preceding paragraph and rendered at inline size. Preserve paragraph boundaries around image output while retaining intentional inline image behavior.
2026-10-16 17:23:43 +02:00
Mohamed Boudra d298141867 fix(app): dismiss review feedback confirmation with a toast
The sent phase kept its confirmation over the diff indefinitely. Use the shared success toast and return the feedback workflow to idle after sending.
2026-10-16 17:09:44 +02:00
paseo-ai[bot] 6b1ec37387 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-16 15:03:55 +00:00
Jiawen Geng 64693f21cf fix(cursor): show plan usage in Cursor agents' context details (#6353)
Cursor agents never showed plan usage cards under their context details.
ACP sessions had no usageSession(), and the Cursor usage source only ran
global discovery, so per-agent discovery always came back empty.

Cursor ACP sessions now report a usage session (provider "cursor" plus
their launch env). The Cursor usage source resolves the cursor-agent
auth.json for that env. Sessions on CURSOR_API_KEY or CURSOR_AUTH_TOKEN,
the in-memory store, or the macOS Keychain (the default there) report
no login.
2026-10-16 16:56:39 +02:00
Mohamed Boudra 0d7a234e63 perf(app): preserve unchanged sidebar workspace projections (#7157)
Reopening a retained sidebar rebuilt its entry collection and invalidated every project row despite unchanged workspace data. Preserve collection identity as well as row identity, while keeping membership and ordering changes observable.
2026-10-16 21:10:42 +07:00
Mohamed Boudra eb1a8a2ec7 fix(markdown): one renderer on react-native-enriched-markdown for iOS, Android and web (#6973)
* fix(app): group native assistant prose into selectable runs

* fix(app): render selectable native prose with enriched markdown

* docs: explain native prose selection boundaries and tradeoffs

* fix(app): preserve markdown semantics and render limits in prose runs

* docs: record native prose compatibility fixes and heading limits

* fix(app): allow additive task lists and admonitions in native prose

* fix(app): preserve inline image formatting and reference scope

* fix(app): preserve markdown source and file actions in native prose

Prepare original source blocks with source-position edits from the assistant parser. Keep reference scope and live/history projection consistent, and retain link text when dispatching workspace file actions.

* fix(app): preserve native prose entities and links while bounding preparation cost

* fix(app): preserve provisional syntax at cached reveal boundaries

* fix(app): bound prose append work and preserve markdown edge cases

* fix(app): preserve home paths and normalized prose links

* fix(app): preserve parser-owned links and streaming tilde tails

* fix(app): keep growing markdown on the streaming block renderer

* fix(app): preserve bracketed IPv6 links in native prose

* fix(app): skip prose classification for blocks over the render cap

* fix(app): share selectable native prose across Markdown surfaces

* fix(app): preserve Markdown containers before grouping prose

* revert(app): remove shared prose-run renderer changes

Reverts ab4737bb2 and ac47e1063 before replacing the Markdown engine across all platforms.

* feat(markdown): use enriched renderer across all platforms

* test: verify library image controls and isolate website config

* fix(markdown): preserve file links and image revisions

* fix(markdown): restore diagrams and preserve link origin

* fix(markdown): retain diagram identity and reference scope

* fix(markdown): avoid empty web history measurements

* fix(markdown): preserve authored empty link titles

* test(markdown): exercise find across whole-message history

* fix(markdown): preserve encoded image paths after rebase

Keep the shared renderer image normalization and file-read regression coverage that originally lived in the main integration merge.

* fix(markdown): derive table and task list colours from the theme

* fix(markdown): restore selection highlight on web

* fix(markdown): restore file link hover tooltips on web

* fix(markdown): normalize inline HTML before rendering

* fix(markdown): create renderer types when applying package patch

The published nightly omits both MarkdownRenderers type files. Represent them as new files so patch-package succeeds on a fresh npm install, matching the already patched development package.

* fix(markdown): keep the renderer tree stable while images resolve

* fix(markdown): size inline images to the line and collapse failed loads

* fix(markdown): normalize only parsed HTML nodes

* fix(markdown): keep the first image reference definition

* fix(markdown): preserve complete assistant messages on native

* fix(markdown): classify web links explicitly and complete renderer types

* fix(markdown): preserve spacing between HTML elements

* fix(markdown): normalize HTML following code elements

* fix(markdown): preserve literal image fallback labels

* fix(markdown): retry transient image decode failures

* docs(markdown): record native selection and image limitations

* fix(markdown): preserve first definitions across Mermaid segments

* fix(markdown): reserve pending images and harden web rendering

* test(markdown): cover state retention during image arrival

* fix(markdown): preserve reader position while responses grow

Keep the same message component boundary when a live row becomes retained history, preserving Mermaid runtimes and selection nodes. Observe paced child text commits at the web viewport so sticky-bottom follows before the next frame.

* test(markdown): require growth while holding a streaming paragraph

* test(markdown): isolate image arrival from reload positioning

Mounted row totals change as history virtualizes, so observe the new response and tool directly. Hold the real PNG response after reload until the reading position settles, then verify its reserved height and viewport instead of attributing initial bottom-follow positioning to image decoding.

* fix(markdown): preserve adjacency around inline HTML

* fix(markdown): keep summary block boundaries outside bold text

* perf(markdown): scan HTML block boundaries incrementally

* test(markdown): gate delivery while establishing a held reader

* test(markdown): name shared renderer setup and file actions

* test(markdown): assert linear HTML boundary work without timing

* test(markdown): check large HTML table output without observing built-ins

* fix(markdown): restore accentBright link colour

* fix(markdown): require letters and known extensions in bare path recognition

* fix(markdown): match main's blockquote, bullet and h6 colours

* fix(android): keep inverted chat still during text selection

React Native decomposes Android list inversion into a 180-degree rotation with positive scaleY, so the existing visibility-scroll guard never recognized the inverted viewport. Inspect the composed matrix and reproduce the actual list transform through the native view manager in regression tests.

* fix(markdown): linked inline code keeps inline code size

* fix(markdown): reserve a placeholder box with an image glyph while images load

* fix(markdown): bare prose paths need an extension, line suffix or depth

Slash-separated English pairs were recognized as file links in prose. Require stronger path intent there while preserving the existing inline-code recognition rule.

* fix(markdown): horizontal blocks own their drags on compact layouts

* perf(markdown): keep native streaming updates in the unfinished tail

Native markdown reparses each updated view in full. Keep parser-closed blocks in a memoized head during paced reveal, then reunite completed prose for cross-paragraph selection. Preserve trailing spacing and source indentation at the split.

* perf(markdown): reuse Android measurements across view mounts

* test(markdown): bare Fabel/model and daemon/client stay plain text when copied

a502cfd0d stopped linking bare two-segment prose paths; the clipboard
expectation still carried the links from the earlier loose pattern and
failed on CI (playwright shard 1/4 on 60b4e2d05). main expects the plain
text, so restore that.

* perf(app): limit native chat mount batches

Whole-message Markdown rows measure synchronously during Fabric layout. Render fewer history rows per batch and retain a smaller offscreen window so opening a chat does not measure twelve history rows at once.

* fix(app): keep populated markdown in sync with native layout

Fabric can collapse streaming blocks before asynchronous rendering replaces the old text, producing a blank frame. Commit populated-view updates synchronously while retaining worker preparation for first mounts to avoid moving large history parsing onto the UI thread.

* perf(app): preserve unchanged sidebar workspace projections

Reopening a retained sidebar rebuilt its entry collection and invalidated every project row despite unchanged workspace data. Preserve collection identity as well as row identity, while keeping membership and ordering changes observable.

* Revert "perf(app): preserve unchanged sidebar workspace projections"

This reverts commit a3919faed4.
2026-10-16 21:03:37 +07:00
Mohamed Boudra d7b3ff654d Show press feedback on sidebar rows, footer buttons, and settings items (#7150)
* fix(app): show press feedback on sidebar rows, footer icons, and settings tiles

Touch has no hover, so these controls gave no acknowledgement when pressed.
Rows take the workspace row's pressed fill; the sidebar header row paints it
on the row so the trailing slot lights up with the button. Footer icon buttons
take the round header-control highlight.

* style(app): format sidebar header row

* fix(app): keep footer icon corners tappable on web

Web hit-testing follows border-radius, so rounding the footer buttons turned
their 44pt square targets into circles and taps near the corners fell through.
Paint the round highlight as a layer inside the square button instead.
2026-10-16 20:46:29 +07:00
303 changed files with 13945 additions and 10476 deletions
+2
View File
@@ -85,6 +85,8 @@ valknut-report.json/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts
packages/protocol/codegen/generated/
/artifacts
packages/desktop/.cache/
+149
View File
@@ -1,5 +1,154 @@
# Changelog
## 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.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
### Added
- Added background workspaces, hidden from the sidebar unless Show background is on ([#5039](https://github.com/getpaseo/paseo/pull/5039), [#7141](https://github.com/getpaseo/paseo/pull/7141) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
- Added a Send feedback button to the diff pane, so review comments reach an agent without opening its chat ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Added a fullscreen composer on phones ([#6531](https://github.com/getpaseo/paseo/pull/6531), [#6902](https://github.com/getpaseo/paseo/pull/6902), [#6945](https://github.com/getpaseo/paseo/pull/6945), [#6987](https://github.com/getpaseo/paseo/pull/6987), [#7130](https://github.com/getpaseo/paseo/pull/7130), [#7170](https://github.com/getpaseo/paseo/pull/7170))
- Added the sender's name to messages one agent sends another ([#6373](https://github.com/getpaseo/paseo/pull/6373))
- Added Claude Mythos 5.1 when the installed Claude Code advertises it ([#6403](https://github.com/getpaseo/paseo/pull/6403))
- Added option previews to Claude question cards ([#6171](https://github.com/getpaseo/paseo/pull/6171) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Added `paseo usage` to show account quotas, balances, and reset times from the CLI ([#6474](https://github.com/getpaseo/paseo/pull/6474))
### Improved
- Changed text selection in replies on iOS and Android to cross paragraphs, headings, and lists ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Added a copy button to code blocks on iOS and Android ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Changed wide Markdown tables on iOS and Android to scroll sideways ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Changed phone layouts to a floating header ([#6431](https://github.com/getpaseo/paseo/pull/6431))
- Moved New agent and New terminal on phones into the tab switcher ([#6431](https://github.com/getpaseo/paseo/pull/6431))
- Moved workspace scripts on phones into ⋯ → Scripts, which shows what is running ([#7122](https://github.com/getpaseo/paseo/pull/7122))
- 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
- Fixed plugins that import Paseo protocol types failing to install with "Could not resolve type dependency" ([#7023](https://github.com/getpaseo/paseo/pull/7023))
- 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
- Fixed iOS replies with list items that render blank, and text that keeps a stale width after a resize or rotation ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Fixed plugin hosts, the CLI, and the desktop app growing to about 1 GB of memory after running for 30 to 75 minutes ([#6847](https://github.com/getpaseo/paseo/pull/6847) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed the daemon running out of memory during long OpenCode v2 reply or reasoning streams ([#6262](https://github.com/getpaseo/paseo/pull/6262))
- Fixed archived ACP agents starting a provider process that kept running each time their timeline loaded ([#6766](https://github.com/getpaseo/paseo/pull/6766) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed oversized attachments disconnecting the host in a loop instead of failing with a size error ([#6285](https://github.com/getpaseo/paseo/pull/6285))
- Fixed Download in Files failing on hosts connected over relay, SSH, or a socket ([#6595](https://github.com/getpaseo/paseo/pull/6595) by [@odoo-mvds](https://github.com/odoo-mvds), [@YASICJUNWOO](https://github.com/YASICJUNWOO), [@JrDw0](https://github.com/JrDw0))
- Fixed Changes showing no diff lines with custom Git diff settings, such as `color.diff=always` or an external diff command ([#6536](https://github.com/getpaseo/paseo/pull/6536), [#6562](https://github.com/getpaseo/paseo/pull/6562) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- 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))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- 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))
- Fixed the Claude context meter counting only output tokens when a proxy reports input usage at the end of the response ([#6703](https://github.com/getpaseo/paseo/pull/6703) by [@kywoo26](https://github.com/kywoo26))
- Fixed the Claude context meter showing a 1M maximum after switching back to a 200k model ([#6814](https://github.com/getpaseo/paseo/pull/6814))
- Fixed the context meter staying empty during the first turn on custom Claude `[1m]` models ([#6767](https://github.com/getpaseo/paseo/pull/6767))
- Fixed a schedule never running again after one of its runs could not be recorded ([#6334](https://github.com/getpaseo/paseo/pull/6334))
- Fixed schedules sharing a cron minute running a slot twice and skipping the next occurrence ([#6532](https://github.com/getpaseo/paseo/pull/6532) by [@keithpk](https://github.com/keithpk))
- Fixed Committed showing no changes for worktrees created from an existing branch ([#6462](https://github.com/getpaseo/paseo/pull/6462) by [@cr-2takumax](https://github.com/cr-2takumax))
- Fixed Discard and commit diffs in Changes for workspaces opened at a repository subdirectory ([#6777](https://github.com/getpaseo/paseo/pull/6777), [#6801](https://github.com/getpaseo/paseo/pull/6801), [#6882](https://github.com/getpaseo/paseo/pull/6882) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed renamed and edited files showing every line as added in Changes ([#6559](https://github.com/getpaseo/paseo/pull/6559))
- Fixed renamed files opened from a commit showing as new files ([#6895](https://github.com/getpaseo/paseo/pull/6895))
- Fixed Changes showing no diff for filenames containing quotes, backslashes, or tabs ([#6583](https://github.com/getpaseo/paseo/pull/6583) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Changes dropping removed lines that start with `--`, such as SQL comments ([#6569](https://github.com/getpaseo/paseo/pull/6569) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed the Diff tab staying on a spinner after the first edit in a clean workspace ([#6745](https://github.com/getpaseo/paseo/pull/6745))
- Fixed worktree creation failing after running `git init` in a project added before Git ([#6785](https://github.com/getpaseo/paseo/pull/6785))
- Fixed new worktrees not opening the terminals configured in `paseo.json` after setup ([#6999](https://github.com/getpaseo/paseo/pull/6999) by [@davidyang](https://github.com/davidyang))
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- 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))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
- Fixed shortcut recording saving the QWERTY key instead of the typed letter on layouts such as Dvorak ([#6652](https://github.com/getpaseo/paseo/pull/6652))
- Fixed rebinding Jump to tab or Jump to workspace keeping only the pressed number instead of 1–9; record existing overrides again ([#7065](https://github.com/getpaseo/paseo/pull/7065) by [@dbunk903](https://github.com/dbunk903))
- Fixed Previous and Next workspace shortcuts stopping at workspace 9 ([#6586](https://github.com/getpaseo/paseo/pull/6586) by [@IEatCodeDaily](https://github.com/IEatCodeDaily))
- Fixed unreadable Android status bar icons in the Light theme and after the system theme changes ([#6707](https://github.com/getpaseo/paseo/pull/6707) by [@terayang](https://github.com/terayang))
- Fixed the pairing QR code getting clipped on iOS and Android ([#6758](https://github.com/getpaseo/paseo/pull/6758) by [@fdkevin0](https://github.com/fdkevin0))
- Fixed a `write EPIPE` error dialog on Linux desktop when the launcher stops reading console output ([#6639](https://github.com/getpaseo/paseo/pull/6639))
- Fixed `paseo permit allow` and `deny` reporting success over SSH while the request stayed pending ([#6607](https://github.com/getpaseo/paseo/pull/6607) by [@budivoogt](https://github.com/budivoogt))
- Fixed files and folders whose names start with two dots, like `..notes.txt`, being hidden and rejected ([#6604](https://github.com/getpaseo/paseo/pull/6604), [#6720](https://github.com/getpaseo/paseo/pull/6720) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Markdown previews dropping table rows after a `<` that isn't HTML, such as `List<Item>` ([#6370](https://github.com/getpaseo/paseo/pull/6370) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed a literal heading tag appearing when an HTML heading contains a linked image ([#6650](https://github.com/getpaseo/paseo/pull/6650))
- Fixed Astro diffs with an empty `{}` expression failing to render ([#6763](https://github.com/getpaseo/paseo/pull/6763))
- Fixed slash command argument hints missing from autocomplete for ACP providers ([#6920](https://github.com/getpaseo/paseo/pull/6920) by [@bwestlund17](https://github.com/bwestlund17))
- Fixed terminal profiles configured with the `agy` icon showing a generic bot instead of the Antigravity icon ([#6938](https://github.com/getpaseo/paseo/pull/6938) by [@noahg9](https://github.com/noahg9))
- 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))
- Fixed the starting-ref picker not finding GitLab merge requests and issues by number ([#6525](https://github.com/getpaseo/paseo/pull/6525))
- Fixed the starting-ref picker hiding change requests that match only in their description ([#6558](https://github.com/getpaseo/paseo/pull/6558))
- Fixed @-mentions not finding plan files under `.cursor/plans` ([#6641](https://github.com/getpaseo/paseo/pull/6641) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
- Fixed Paseo replacing a symlinked `config.json` with a regular file when saving settings ([#6023](https://github.com/getpaseo/paseo/pull/6023))
- Fixed the published config schema rejecting valid settings such as `agentProfiles` and `plugins` ([#6621](https://github.com/getpaseo/paseo/pull/6621) by [@ragokan](https://github.com/ragokan))
- Fixed duplicating a folder turning its relative symlinks into absolute links to the original ([#6771](https://github.com/getpaseo/paseo/pull/6771) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed renaming an untracked file like `[id].tsx` failing when a tracked file matches it as a pattern ([#6573](https://github.com/getpaseo/paseo/pull/6573) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed saving workspace files with long names failing with `ENAMETOOLONG` ([#6658](https://github.com/getpaseo/paseo/pull/6658) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed worktree creation and Changes failing for projects whose folder name ends in a space ([#6686](https://github.com/getpaseo/paseo/pull/6686))
- Fixed merging a worktree when the base branch's worktree path contains a newline or ends in a space ([#6668](https://github.com/getpaseo/paseo/pull/6668) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo agent ls` listing only the first 200 agents ([#6033](https://github.com/getpaseo/paseo/pull/6033) by [@hteo1337](https://github.com/hteo1337))
- Fixed `paseo worktree ls` and `paseo worktree archive` failing with "cwd or repoRoot is required" ([#6467](https://github.com/getpaseo/paseo/pull/6467) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed `paseo worktree ls` listing an archived worktree for up to 15 seconds ([#6545](https://github.com/getpaseo/paseo/pull/6545) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo logs --since` printing activity from before the requested timestamp ([#6514](https://github.com/getpaseo/paseo/pull/6514) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo logs --follow` splitting words across lines as text streams in ([#6528](https://github.com/getpaseo/paseo/pull/6528) by [@fiorelorenzo](https://github.com/fiorelorenzo))
- Fixed `paseo logs` printing reasoning after the reply that followed it ([#6546](https://github.com/getpaseo/paseo/pull/6546))
- Fixed the daemon keeping files open after a canceled HTTP download ([#6567](https://github.com/getpaseo/paseo/pull/6567) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Linux desktop windows showing a generic icon instead of the Paseo icon ([#6645](https://github.com/getpaseo/paseo/pull/6645) by [@whme](https://github.com/whme), [@panta82](https://github.com/panta82))
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
## 0.11.4 - 2026-10-15
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
+2
View File
@@ -49,6 +49,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
+10 -19
View File
@@ -12,7 +12,7 @@ provider deltas (every provider streams incrementally)
→ recordTimeline: one canonical row per flushed item
→ agent_stream ws message
→ reducer queue (app, one commit per frame) → session store
→ source-item plugin transforms → native Markdown blocks / tool grouping
→ source-item plugin transforms → Markdown presentation / tool grouping
→ paced reveal (app, per displayed item) → paint
```
@@ -29,23 +29,8 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **One source assistant message is one display row.** Presentation projects plugin output and caches unchanged history. The shared [Markdown renderer](markdown-renderer.md) owns rendering on every platform. Chat paces the visible source and applies a 32,000-character render cap on web; native keeps the complete message. The native renderer keeps closed blocks in a stable head and sends the paced suffix to a separate tail view during streaming; completion reunites them for selection. This stays inside the renderer, without promoting blocks into timeline history.
- **Message identity survives completion.** Assistant row ids are source message ids. Plugin rows can replace a source, so find, scroll-to-message and history reveal still use `getStreamItemMessageId`. Width-specific measurements describe whole rendered messages.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
@@ -54,7 +39,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
through that hook; the web viewport once skipped it and history hosts of a live tool group went
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
silently stops.
@@ -63,6 +48,12 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
while text arrives, and release obsolete window caches as the reader reveals more history;
window caches must stay bounded while their source timeline remains loaded.
## Native parsing cost
The native library reparses and measures its entire `markdown` prop on each update. During streaming, the renderer splits source at an mdast block boundary and memoizes the head, so per-frame updates parse only the tail. Boundary discovery runs when a source line completes, rather than on every reveal frame. The unfinished line and final parsed block stay in the tail, preserving list continuation, setext headings, tables and open fences. Reference-dependent source and HTML retain a larger tail because later source can change their meaning. A single long block therefore still carries its full parsing cost.
Completion restores one native text view per Mermaid-delimited segment, so selection crosses prose paragraphs again. The head retains its trailing block margin while split. JS reveal remains the pacing source; native opacity animation stays disabled.
## Measuring
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
+3 -3
View File
@@ -205,12 +205,12 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
+31
View File
@@ -0,0 +1,31 @@
# Markdown rendering
Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and changelog content. It uses `react-native-enriched-markdown` on iOS, Android and web. Keep source adaptation and renderer configuration in `components/markdown`; consumers supply Markdown and, when needed, its file location.
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
## Links and images
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. 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
The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars.
Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Comment HTML is normalized to Markdown before parsing; details show their summary and body expanded.
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
## Verification
Follow [QA](qa.md) and [mobile testing](mobile-testing.md). Exercise chat, file preview, plan and PR content on all three platforms. Include cross-paragraph selection, progressive streaming, a file-link tap, code copy/highlighting, a table and a workspace image. On iPad, repeat preview selection after opening and closing Explorer and after rotation. Check web find, selection-copy and source-message navigation after DOM changes.
+22 -5
View File
@@ -300,16 +300,33 @@ done
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
Run the microphone-privacy cases on Android 12 or newer.
Verify these distinct outcomes:
- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
access resumes it. Starting dictation while access is disabled waits for the same recovery.
- Returning from music controls resumes dictation without pausing music again. Cancelling a
backgrounded recording must prevent the lifecycle callback from restarting it.
- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
# Start voice mode in an existing composer, then background Paseo.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
# Foreground Paseo while the call is still ringing, then end the simulated call.
adb emu gsm cancel 5551234
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
Use browser/device automation to operate the app; the commands above only control emulator telephony.
This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
### Releasing the audio session when idle
+24
View File
@@ -0,0 +1,24 @@
# Native text selection
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
## Android inverted chat
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
into rotation with positive `scaleY`, so test the composed matrix when deciding
whether those requests should move the viewport. Native tests must apply the
list's transform through React Native's view manager; assigning `scaleY` directly
misses that decomposition.
## Diagrams in lists and quotes
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
Reopening a list marker or quote prefix on the following fragment cannot place the diagram inside the original container or continue its quote rule. Preserving the group requires a native block renderer hook or a host that owns the whole group. Keep native diagrams at the top level until that exists; nested grouping remains a known limitation.
## Leading inline images
Native image decoders do not support SVG badges, so the renderer shows their alt text. Pending images share the web placeholder: a reserved image box with a small centred image glyph; confirmed acquisition or decoding failures show alt text.
The native library classifies an image from the text preceding it. A raster image at the start of a paragraph uses block sizing even when text follows; the inline size setting only affects images the library recognizes as inline. Correcting that classification requires a native library change and new app builds. JavaScript-only updates retain that classification until native app builds include a library fix.
+6 -5
View File
@@ -6,7 +6,9 @@ The reason is mobile performance. A captured 353 KB provider snapshot cost about
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It validates the envelope with a generated validator that checks only each session message's `type` tag, then runs the generated validator for that one message type and returns the validated data. Issue paths from the message validator, including nested union branches, are prefixed with `message`, so callers see the same issues as when one validator covered the whole envelope. It does not normalize, repair, or re-validate the generated result.
Keep one generated function per message type. zod-aot inlines every nested schema into the function it compiles, so compiling `WSOutboundMessageSchema` as a whole produced one 11 MB function. On V8 (daemon clients, plugin hosts, the CLI, and Electron), the Maglev tier compiled that function once it was hot, allocating about 800 MB and stalling for seconds, and compiled it again after each deoptimization. Hermes has no JIT, so mobile never showed it. `packages/protocol/tests/validation/ws-outbound.test.ts` fails if a generated function grows past 1 MB.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
@@ -16,11 +18,10 @@ Provider model normalization is a parser-side compatibility shim in the client c
The protocol package owns generation.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/src/validation/ws-outbound-envelope.ts` defines the tag-only envelope schema and exposes the session message schemas that get their own validators.
- `packages/protocol/scripts/generate-validation-aot.mjs` writes the zod-aot discovery entry and the runtime schema metadata, runs the exact-pinned compiler, and applies the small local compiler patches before generation. It also emits the `type` → validator map the boundary dispatches through.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
- `packages/protocol/codegen/generated/ws-outbound.compile.ts`, `packages/protocol/src/generated/validation/ws-outbound.aot.ts`, and `packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts` are generated and gitignored. The metadata module lets generated code reach schema fallback/default values.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+13 -10
View File
@@ -305,12 +305,14 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
@@ -334,7 +336,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# Inspect the full release workflow, including distribute_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
@@ -347,17 +349,17 @@ npx eas build:view <build-id> --json | jq '.logFiles[]'
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
@@ -380,8 +382,9 @@ Windows, and Android APK assets are present along with the channel manifests
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
For stable releases, also confirm every required mobile build, upload, store
submission, and review-submission job for the release commit. For betas, confirm
For stable releases, also confirm every required mobile build, external
TestFlight distribution, Beta App Review submission, and store submission job
for the release commit. For betas, confirm
the beta EAS workflow completed its TestFlight distribution and Beta App Review
path. Delete the heartbeat only after every applicable checklist item passes,
then report the release as shipped.
@@ -396,7 +399,7 @@ Pattern:
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
```
@@ -680,7 +683,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
+1 -1
View File
@@ -1 +1 @@
sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
+69 -96
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -16265,15 +16265,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
@@ -17088,15 +17079,6 @@
"node": ">=8"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
@@ -17122,17 +17104,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
@@ -30248,6 +30219,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -30259,6 +30231,7 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/proper-lockfile": {
@@ -30905,15 +30878,6 @@
"react-native": "*"
}
},
"node_modules/react-native-fit-image": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
"license": "Beerware",
"dependencies": {
"prop-types": "^15.5.10"
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
@@ -30939,22 +30903,6 @@
"react-native": "*"
}
},
"node_modules/react-native-markdown-display": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
"license": "MIT",
"dependencies": {
"css-to-react-native": "^3.0.0",
"markdown-it": "^10.0.0",
"prop-types": "^15.7.2",
"react-native-fit-image": "^1.5.5"
},
"peerDependencies": {
"react": ">=16.2.0",
"react-native": ">=0.50.4"
}
},
"node_modules/react-native-nitro-modules": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.5.tgz",
@@ -31020,18 +30968,6 @@
"react-native": "*"
}
},
"node_modules/react-native-uitextview": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-native-uitextview/-/react-native-uitextview-2.2.0.tgz",
"integrity": "sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==",
"workspaces": [
"example"
],
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-unistyles": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
@@ -36413,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36480,7 +36416,11 @@
"i18next": "^26.3.0",
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-gfm-table": "^2.0.0",
"mermaid": "^11.16.0",
"micromark-extension-gfm-table": "^2.1.2",
"micromark-util-decode-string": "2.0.1",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -36490,15 +36430,14 @@
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-uitextview": "^2.2.0",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
@@ -37350,6 +37289,23 @@
}
}
},
"packages/app/node_modules/micromark-extension-gfm-table": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
"integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"packages/app/node_modules/miniflare": {
"version": "4.20260625.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
@@ -37378,6 +37334,23 @@
"dev": true,
"license": "MIT"
},
"packages/app/node_modules/react-native-enriched-markdown": {
"version": "1.2.0-nightly-20261014-766fc0e67",
"resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.2.0-nightly-20261014-766fc0e67.tgz",
"integrity": "sha512-PyYAAVJAyjlcqHXgSnzxok13MDmyxs/nt46hHIOBdCc0prfaL2Ef6nYkZu7fFCeaETOJXd/xQr4n2/79I+gvdA==",
"hasInstallScript": true,
"license": "MIT",
"peerDependencies": {
"katex": ">=0.16.0",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"katex": {
"optional": true
}
}
},
"packages/app/node_modules/react-native-keyboard-controller": {
"version": "1.21.12",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
@@ -37496,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/server": "0.11.2",
"@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",
@@ -37748,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38006,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38931,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@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",
@@ -38948,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@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"
@@ -38971,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.2",
"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.11.2",
"@getpaseo/highlight": "0.11.2",
"@getpaseo/plugin": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"@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",
@@ -40146,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41754,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.2",
"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.11.2",
"version": "0.12.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -23,13 +23,16 @@ jobs:
platform: android
profile: production
submit_ios:
name: Submit iOS
distribute_ios:
name: Distribute iOS to TestFlight
needs: [build_ios]
type: submit
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
submit_android:
name: Submit Android
@@ -41,7 +44,7 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, submit_ios]
needs: [build_ios, distribute_ios]
environment: production
runs_on: macos-medium
steps:
+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
@@ -1154,17 +1154,15 @@ test.describe("Agent message submission", () => {
page,
page.getByTestId("assistant-message").last(),
);
const assistantMessageCount = await page.getByTestId("assistant-message").count();
const toolCallCount = await page.getByTestId("tool-call-badge").count();
await composer.press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
await expect(userMessage).toBeVisible();
await expect
.poll(async () => page.getByTestId("assistant-message").count())
.toBeGreaterThan(assistantMessageCount);
await expect
.poll(async () => page.getByTestId("tool-call-badge").count())
.toBeGreaterThan(toolCallCount);
await expect(
page.getByTestId("assistant-message").filter({
hasText: "I'll take a look at the scroll anchor behavior you described.",
}),
).toBeVisible();
await awaitToolCall(page, "Read");
await finishTimelineRowStabilityCheck();
});
@@ -1,3 +1,12 @@
import type { Page } from "@playwright/test";
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
import {
expectCompletedDiagram,
openDiagramFullscreen,
expectFullscreenDiagram,
} from "../support/helpers/diagram";
import { expect, test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
@@ -17,6 +26,10 @@ import {
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import path from "node:path";
import {
rememberTimelineViewport,
scrollTimelinePromptIntoView,
} from "../support/helpers/timeline-pagination";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -97,11 +110,11 @@ test("opens a timeline image in a zoomable lightbox", async ({
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.getByRole("button", { name: "Open image attachment" }).click();
await page.getByRole("button", { name: image.alt }).click();
const lightboxImage = page.getByTestId("attachment-lightbox-image");
const canvas = page.getByTestId("attachment-lightbox-canvas");
await expect(lightboxImage).toBeVisible();
await canvas.hover();
await page.getByTestId("zoomable-viewport-toolbar").hover();
const transformedContent = canvas.locator(":scope > div").first();
const initialBox = await transformedContent.boundingBox();
const canvasBox = await canvas.boundingBox();
@@ -133,7 +146,7 @@ test("opens a timeline image in a zoomable lightbox", async ({
await expectAgentReadyToInterrupt(page);
});
test("reloading a timeline anchors near-tail assistant image growth", async ({
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
imageWorkspace: workspace,
page,
}) => {
@@ -143,119 +156,34 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
fileName: "reload-geometry-image.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
try {
delivery.release();
await emitSettledAssistantImage(workspace.client, imageAgent, image, delivery.url);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.addInitScript(
({ accessibleName }) => {
interface ImageGeometryEvent {
atMs: number;
complete: boolean;
height: number;
naturalHeight: number;
naturalWidth: number;
scrollTop: number;
width: number;
}
const state = {
accessibleName,
events: [] as ImageGeometryEvent[],
mutationObserver: null as MutationObserver | null,
resizeObserver: null as ResizeObserver | null,
target: null as Element | null,
};
const record = () => {
const target = state.target;
if (!target) return;
const imageElement =
target instanceof HTMLImageElement ? target : target.querySelector("img");
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
const rect = target.getBoundingClientRect();
const event: ImageGeometryEvent = {
atMs: performance.now(),
complete: imageElement?.complete ?? false,
height: rect.height,
naturalHeight: imageElement?.naturalHeight ?? 0,
naturalWidth: imageElement?.naturalWidth ?? 0,
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
width: rect.width,
};
const previous = state.events.at(-1);
if (
!previous ||
previous.complete !== event.complete ||
previous.height !== event.height ||
previous.naturalHeight !== event.naturalHeight ||
previous.naturalWidth !== event.naturalWidth ||
previous.scrollTop !== event.scrollTop ||
previous.width !== event.width
) {
state.events.push(event);
}
};
const inspect = () => {
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
(element) => element.getAttribute("aria-label") === accessibleName,
);
if (!target || target === state.target) {
record();
return;
}
state.target = target;
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(record);
state.resizeObserver.observe(target);
record();
};
const start = () => {
state.mutationObserver = new MutationObserver(inspect);
state.mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["src", "style"],
childList: true,
subtree: true,
});
inspect();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
(
window as unknown as {
__paseoReloadImageGeometry?: typeof state;
}
).__paseoReloadImageGeometry = state;
},
{ accessibleName: image.alt },
);
delivery.holdNextRequest();
await page.reload();
await delivery.waitUntilRequested();
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
await scrollTimelinePromptIntoView(
page,
"image-history-turn-2: emit 1 coalesced agent stream updates",
);
const beforeLoad = await rememberTimelineViewport(page);
const reservedHeight = (await rendered.boundingBox())?.height;
expect(reservedHeight).toBe(320);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.reload();
await expectAssistantImageRendered(page, image);
await page.waitForTimeout(500);
const events = await page.evaluate(() => {
const state = (
window as unknown as {
__paseoReloadImageGeometry?: { events: Array<unknown> };
}
).__paseoReloadImageGeometry;
if (!state) throw new Error("Reload image geometry observer was not installed");
return state.events;
});
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
const initialGeometry = geometryEvents.find((event) => event.height > 0);
if (!initialGeometry)
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
if (!expandedGeometry)
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
initialGeometry.scrollTop,
);
delivery.release();
await expectAssistantImageRendered(page, image);
expect((await rendered.boundingBox())?.height).toBe(reservedHeight);
await expect.poll(() => rememberTimelineViewport(page)).toEqual(beforeLoad);
} finally {
await delivery.close();
}
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
@@ -296,3 +224,222 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive",
response: "stress-update-0",
});
});
test("a later reply reads new bytes at the same workspace image path", async ({
imageWorkspace: workspace,
page,
}) => {
const agent = await createSettledMockAgent(workspace, "Screenshot revisions");
const first = await createSmallAssistantPng(workspace, {
alt: "Earlier screenshot",
fileName: "screenshot.png",
});
await emitSettledAssistantImage(workspace.client, agent, first);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, first);
const second = await createSmallAssistantPng(workspace, {
alt: "Updated screenshot",
fileName: "screenshot.png",
width: 2,
height: 3,
});
await emitSettledAssistantImage(workspace.client, agent, second);
await expectAssistantImageRendered(page, second);
await expectAssistantImageRendered(page, first);
});
test("pending assistant images reserve their decoded height", async ({
imageWorkspace: workspace,
page,
}, testInfo) => {
const image = await createSmallAssistantPng(workspace, {
alt: "Delayed remote image",
fileName: "delayed.png",
width: 32,
height: 24,
});
const agent = await createSettledMockAgent(workspace, "Pending image geometry");
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
const { url } = delivery;
try {
await emitSettledAssistantImage(workspace.client, agent, image, url);
await openAssistantImageTimeline(page, agent);
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
await delivery.waitUntilRequested();
const pendingBox = await rendered.boundingBox();
expect(pendingBox?.height).toBe(320);
await rendered.click();
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("pending-image.png") });
delivery.release();
await expect(rendered).toHaveAttribute("src", url);
expect((await rendered.boundingBox())?.height).toBe(pendingBox?.height);
} finally {
await delivery.close();
}
});
/** Serve the fixture's real PNG bytes; release controls when the decoder can finish. */
async function serveDelayedImage(file: string) {
let release!: () => void;
let ready: Promise<void>;
let requested!: () => void;
let requestStarted: Promise<void>;
const holdNextRequest = () => {
ready = new Promise<void>((resolve) => {
release = resolve;
});
requestStarted = new Promise<void>((resolve) => {
requested = resolve;
});
};
holdNextRequest();
const server = createServer((_request, response) => {
requested();
void ready.then(() => {
response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
return createReadStream(file).pipe(response);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Image server did not listen");
return {
url: `http://127.0.0.1:${address.port}/image.png`,
release: () => release(),
holdNextRequest,
waitUntilRequested: () => requestStarted,
close: async () => {
release();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
test("late workspace image completion preserves selected prose", async ({
imageWorkspace,
page,
}) => {
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
const selected = await selectImageFixtureParagraphs(page);
release();
await expectAssistantImageRendered(page, image);
await expectSelectedParagraphsRetained(page, selected);
});
});
test("late workspace image completion keeps Mermaid fullscreen open", async ({
imageWorkspace,
page,
}) => {
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Done"]);
release();
await expectAssistantImageRendered(page, image);
await expectFullscreenDiagram(page, ["Start", "Done"]);
});
});
async function withPendingWorkspaceImage(
page: Page,
workspace: SeededWorkspace,
run: (
image: Awaited<ReturnType<typeof createSmallAssistantPng>>,
release: () => void,
) => Promise<void>,
) {
const image = await createSmallAssistantPng(workspace, {
alt: "Late workspace image",
fileName: "late.png",
width: 32,
height: 24,
});
const source = [
"Paragraph one stays selected while the workspace image loads.",
"Paragraph two remains in this reply.",
"Paragraph three is the selection endpoint.",
"```mermaid\nflowchart LR\n Start --> Done\n```",
`![${image.alt}](${image.relativePath})`,
].join("\n\n");
const agent = await createSettledMockAgent(workspace, "Delayed workspace read", source);
await emitSettledAssistantImage(workspace.client, agent, image);
const gate = await delayFileReadResponse(page, image.relativePath);
let released = false;
const release = () => {
if (!released) {
released = true;
gate.release();
}
};
try {
await openAssistantImageTimeline(page, agent);
await gate.waitUntilHeld();
await expectCompletedDiagram(page, ["Start", "Done"]);
await run(image, release);
} finally {
release();
}
}
type SelectionProofWindow = Window & { __lateImageSelection?: { start: Node; end: Node } };
async function selectImageFixtureParagraphs(page: Page): Promise<string> {
const message = page.getByTestId("assistant-message");
await message
.getByText("Paragraph one stays selected while the workspace image loads.", { exact: true })
.scrollIntoViewIfNeeded();
const bounds = await message.evaluate((root) => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let start: Text | undefined;
let end: Text | undefined;
while (walker.nextNode()) {
const text = walker.currentNode as Text;
if (text.data.startsWith("Paragraph one stays")) start = text;
if (text.data.startsWith("Paragraph three is")) end = text;
}
if (!start || !end) throw new Error("Selection paragraphs are missing");
(window as SelectionProofWindow).__lateImageSelection = { start, end };
const first = document.createRange();
first.selectNodeContents(start);
const last = document.createRange();
last.selectNodeContents(end);
return {
first: first.getBoundingClientRect().toJSON(),
last: last.getBoundingClientRect().toJSON(),
};
});
await page.mouse.move(bounds.first.x + 1, bounds.first.y + 5);
await page.mouse.down();
await page.mouse.move(bounds.last.right - 1, bounds.last.bottom - 2, { steps: 20 });
await page.mouse.up();
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("Paragraph one");
expect(selected).toContain("Paragraph three");
return selected;
}
async function expectSelectedParagraphsRetained(page: Page, selected: string) {
await expect
.poll(() =>
page.evaluate(() => {
const proof = (window as SelectionProofWindow).__lateImageSelection;
return {
startConnected: proof?.start.isConnected,
endConnected: proof?.end.isConnected,
text: window.getSelection()?.toString(),
};
}),
)
.toEqual({ startConnected: true, endConnected: true, text: selected });
}
@@ -10,6 +10,8 @@ import {
reportScrollJumps,
scrollCadences,
withVariedTimeline,
withStreamingReader,
holdParagraphThroughCompletion,
} from "../support/helpers/timeline-scroll-smoothness";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
@@ -202,3 +204,22 @@ for (const cadence of scrollCadences) {
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
await expectImageSpaceReserved(page, testInfo);
});
test("holds a paragraph still while a mixed response streams and completes", async ({ page }) => {
await withStreamingReader(async (agent) => {
const reading = await holdParagraphThroughCompletion(page, agent);
expect(
reading.textGrowth,
"the held reader must observe an actively growing response",
).toBeGreaterThan(1000);
expect(
reading.maxFollowGap,
"follow-bottom must catch each streamed commit before the next frame",
).toBeLessThanOrEqual(1);
expect(
reading.maxDrift,
"streaming must preserve the held paragraph's viewport position",
).toBeLessThanOrEqual(1);
expect(reading.retained, "completion must retain the paragraph's DOM node").toBe(true);
});
});
@@ -2,7 +2,9 @@ import { expect, test } from "../support/fixtures";
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
test("caps a newline-heavy assistant message on web before rendering markdown", async ({
page,
}) => {
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
const agent = await seedMockAgentWorkspace({
@@ -73,9 +73,14 @@ const ASSISTANT_MARKDOWN = [
].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
"",
"www.example.com stays",
"[www.example.com](http://www.example.com) stays",
)
.replace(
"`https://example.com/generated` stays",
"[`https://example.com/generated`](https://example.com/generated) stays",
)
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
.replace(
"<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)",
@@ -98,8 +103,7 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
}
/**
* The rows of one assistant message. A message renders one row per Markdown block, so
* every locator and every text walk below covers the group, not a single block.
* Locate assistant rows by source-message identity.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
@@ -173,7 +177,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
const blocks = assistantMessageBlocks(page);
const target = blocks.getByText(text);
await target.scrollIntoViewIfNeeded();
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
const frame = await blocks.locator("img").boundingBox();
const end = await target.evaluate((element, selectedText) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
@@ -192,7 +196,8 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
if (!frame) {
throw new Error("Expected a rendered image");
}
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.mouse.move(end.left + 1, end.y - 14);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 10 });
await page.mouse.up();
@@ -210,18 +215,12 @@ async function selectAssistantListItemFromMarker(
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.locator(listTag)
.filter({ hasText: itemText })
.locator(':scope > [data-paseo-markdown-tag="li"]')
.locator(":scope > li")
.filter({ hasText: itemText });
await item.evaluate((element, selectedEndText) => {
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
const markerText = marker?.firstChild;
if (!(markerText instanceof Text)) {
throw new Error("Expected rendered list marker text");
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let endNode: Text | null = null;
let endOffset = -1;
@@ -239,7 +238,7 @@ async function selectAssistantListItemFromMarker(
}
const range = document.createRange();
range.setStart(markerText, 0);
range.setStart(element, 0);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
@@ -254,7 +253,7 @@ async function doubleClickAssistantMarkdownText(
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.locator(tag)
.filter({ hasText: text })
.dblclick({ position: { x: 5, y: 5 } });
}
@@ -276,39 +275,23 @@ async function selectAssistantTextRange(
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
let endOffset = -1;
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
}
}
if (startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (offset >= 0) {
endNode = textNode;
endOffset = offset + selectedRange.endText.length;
break;
}
}
textNode = walker.nextNode();
}
if (!startNode || !endNode) {
const text = nodes.map((node) => node.data).join("");
const start = text.indexOf(selectedRange.startText);
const end = text.indexOf(selectedRange.endText, start);
if (start < 0 || end < 0)
throw new Error(
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
);
}
const boundary = (offset: number, isEnd = false): [Text, number] => {
for (const node of nodes) {
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
offset -= node.length;
}
throw new Error("Selection extends beyond message");
};
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
range.setStart(...boundary(start));
range.setEnd(...boundary(end + selectedRange.endText.length, true));
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
@@ -428,16 +411,12 @@ test("copying an assistant selection preserves Markdown structure and links", as
["em", "emphasized prose"],
["s", "struck prose"],
]) {
const formattedProse = assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text });
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
await expect(formattedProse).toHaveCSS("font-family", "serif");
await expect(formattedProse).not.toHaveAttribute("data-pmono");
}
const inlineCode = assistantMessage
.locator('[data-paseo-markdown-tag="code"]')
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", "");
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
await selectStructuredClipboardFixture(page);
await copySelection(page);
@@ -466,7 +445,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
expect(clipboard.html).not.toContain(
expect(clipboard.html).toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
);
expect(clipboard.html).toContain('<code class="language-typescript">');
+90 -12
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";
@@ -33,9 +38,10 @@ const STREAMED_RESPONSE = [
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
// One message far taller than the viewport, so its first and last paragraph rows are
// never in the virtualizer's window at the same time.
// One message taller than the viewport, exercising navigation between distant
// matches inside the same rendered row.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const VIRTUALIZED_TURN_COUNT = 60;
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
@@ -119,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) => {
@@ -173,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;
});
@@ -218,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,
}) => {
@@ -322,11 +399,11 @@ test("finds a hit in a message that streamed while the chat was open", async ({
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
// The only hit is in the first paragraph, which is no longer the row that grows.
// The first-paragraph hit must remain searchable while the same message grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
// One message, two paragraphs, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
@@ -346,12 +423,14 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
for (let index = 0; index < VIRTUALIZED_TURN_COUNT; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
await expect(
page.getByText(`Prompt number ${VIRTUALIZED_TURN_COUNT - 1}`, { exact: true }),
).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
@@ -360,9 +439,8 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
}
}
// Every block row of the selected message must be mounted for Find to see it, whatever
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
// from the count and Next walks off to the next message instead.
// Find must count distant paragraphs in one source message as well as messages
// outside the mounted history window. Next must not skip a same-message hit.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
@@ -370,9 +448,9 @@ test("counts every occurrence of a virtualized message and steps between them",
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectFindStatus(page, "span-needle", `1 of ${VIRTUALIZED_TURN_COUNT * 2}`);
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await expectNextMatch(page, `2 of ${VIRTUALIZED_TURN_COUNT * 2}`, "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
@@ -310,7 +310,7 @@ async function sendFeedback(page: Page, count: number) {
}
async function expectFeedbackSent(page: Page, recipient: string) {
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
await expect(page.getByTestId("app-toast")).toHaveText(`Feedback sent to ${recipient}`);
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
}
@@ -340,6 +340,9 @@ test.describe("send review feedback", () => {
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Please simplify this.");
await captureFeedback(page, "single");
await expect(
page.getByText("Feedback sent to Composer diff stat", { exact: true }),
).toHaveCount(0);
});
await test.step("Return to the untouched composer draft", async () => {
await showWideLayout(page);
@@ -375,11 +378,7 @@ test.describe("send review feedback", () => {
).toBeDisabled();
expect(gate.requestCount()).toBe(1);
gate.accept();
await expect(
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
exact: true,
}),
).toBeVisible();
await expectFeedbackSent(page, "Composer diff stat");
});
} finally {
await workspace.cleanup();
+34 -3
View File
@@ -72,10 +72,10 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("img", { name, exact: true });
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
await expect
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
.toBeGreaterThan(0);
}
@@ -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-",
@@ -221,7 +252,7 @@ test.describe("CodeMirror workspace file editing", () => {
const fileLink = page.getByRole("link", { name: "Open target" }).first();
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.hover();
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
await expect(fileLink).toHaveAttribute("title", "src/target.ts:42-45");
} finally {
await workspace.cleanup();
}
@@ -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);
});
});
@@ -0,0 +1,42 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
async function openProjectHome(page: Page) {
await page.goto("/open-project");
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
}
async function scrollToHomeFooter(page: Page) {
await page.mouse.move(160, 350);
await page.mouse.wheel(0, 1500);
}
async function expectHomeActionsReachable(page: Page) {
const providers = page.getByTestId("open-project-setup-providers");
const community = page.getByRole("button", { name: "Community", exact: true });
await expect(providers).toBeInViewport({ ratio: 1 });
await expect(community).toBeInViewport({ ratio: 1 });
const providerBounds = await providers.boundingBox();
const communityBounds = await community.boundingBox();
expect(providerBounds).not.toBeNull();
expect(communityBounds).not.toBeNull();
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
}
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 320, height: 568 });
await openProjectHome(page);
await scrollToHomeFooter(page);
await expectHomeActionsReachable(page);
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
});
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openProjectHome(page);
await expectHomeActionsReachable(page);
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
"-webkit-app-region",
"none",
);
});
@@ -1,15 +1,9 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The plan card has to pass its own markdown parser. Drop that prop and
// react-native-markdown-display silently supplies one with `typographer: true`
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
// No unit test can catch that, because the defect lives in the JSX.
//
// Nothing here is provider-specific: the mock provider drives it, and the
// behavior is the same for every agent.
// Literal shell commands and copyright markers must survive Markdown rendering.
test.describe("Plan card markdown", () => {
test("renders plan text verbatim", async ({ page }) => {
test.setTimeout(180_000);
@@ -25,6 +19,7 @@ test.describe("Plan card markdown", () => {
await waitForPermissionPrompt(page, 120_000);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard.locator(".enrm-text")).toBeVisible();
await expect(planCard).toContainText("(c)");
await expect(planCard).toContainText('--name="my repo"');
await expect(planCard).toContainText("---buzz");
@@ -35,3 +30,232 @@ test.describe("Plan card markdown", () => {
}
});
});
test("chat and file preview share the library renderer and resolve file links", async ({
page,
}, testInfo) => {
await withSharedMarkdownFiles(page, async () => {
const message = page.getByTestId("assistant-message");
await expect(message).toHaveCount(1);
await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
await expect(message.locator("blockquote")).toContainText("A quote");
await expect(message.getByRole("table")).toContainText("42");
await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
await expect(message.getByRole("cell").nth(1)).toHaveText("");
await expect(message.getByRole("cell").nth(2)).toHaveText("42");
await expect(message.locator("p div")).toHaveCount(0);
await openSharedMarkdownFile(page, "README.md");
const preview = page.getByTestId("markdown-preview-frame");
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
await expect(preview.getByRole("table")).toContainText("42");
await returnToSharedMarkdownChat(page);
await expectFileLinkTooltip(
page,
message.getByRole("link", { name: "src/说明.md:2-3", exact: true }),
"packages/app/src/说明.md:2-3",
);
await openSharedMarkdownFile(page, "src/说明.md:2-3");
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await openSharedMarkdownFile(page, "packages/app/src/说明.md:2-3");
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await openSharedMarkdownFile(page, "Space file");
await expect(
page.getByText("Explicit link opened the space filename.", { exact: true }),
).toBeVisible();
await openSharedMarkdownFile(page, "my%20file.ts");
await expect(
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
).toBeVisible();
await testInfo.attach("shared-renderer", {
body: await page.screenshot(),
contentType: "image/png",
});
});
});
async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
await link.hover();
await expect(page.getByRole("tooltip")).toHaveText(path);
}
async function withSharedMarkdownFiles(page: Page, run: () => Promise<void>) {
const { writeFile, mkdir } = await import("node:fs/promises");
const source =
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`, or packages/app/src/说明.md:2-3.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
const session = await seedMockAgentWorkspace({
repoPrefix: "shared-markdown-",
title: "Shared Markdown",
featureValues: { mockAssistantResponse: source },
initialPrompt: "Show Markdown",
});
try {
await writeFile(`${session.cwd}/README.md`, source);
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
await writeFile(
`${session.cwd}/my%20file.ts`,
"Inline code opened the literal percent filename.",
);
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
await writeFile(
`${session.cwd}/packages/app/src/说明.md`,
"# Nested file\n\nUnicode suffix lookup succeeded.",
);
await session.client.waitForFinish(session.agentId, 30_000);
// A cold render must work even when a separate parser chunk cannot be fetched.
await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
await openAgentRoute(page, session);
await run();
} finally {
await session.cleanup();
}
}
async function returnToSharedMarkdownChat(page: Page) {
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
}
async function openSharedMarkdownFile(page: Page, name: string) {
await returnToSharedMarkdownChat(page);
await page.getByTestId("assistant-message").getByRole("link", { name, exact: true }).click();
}
test("linked inline code keeps code typography and wraps long paths", async ({
page,
}, testInfo) => {
const longPath =
"packages/app/src/components/markdown/renderer/very-long-directory-name/another-long-directory-name/file.ts:12";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-code-style-",
title: "Inline code style",
featureValues: {
mockAssistantResponse:
"Compare `ordinary_token`, `file.ts:12`, `https://example.com`. At the end of this line open `" +
longPath +
"`.",
},
initialPrompt: "Show inline code",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: longPath, exact: true })).toBeVisible();
const measurements = await measureInlineCode(message);
await testInfo.attach("inline-code-measurements", {
body: JSON.stringify(measurements, null, 2),
contentType: "application/json",
});
expect(measurements.codes).toHaveLength(4);
const [plain, file, url, long] = measurements.codes;
for (const linked of [file, url, long]) {
expect(linked.fontSize).toBe(plain.fontSize);
expect(linked.fontFamily).toBe(plain.fontFamily);
expect(linked.backgroundColor).toBe(plain.backgroundColor);
expect(linked.color).toBe(linked.anchorColor);
expect(linked.color).not.toBe(plain.color);
}
expect(long.lines).toBeGreaterThan(1);
expect(long.right).toBeLessThanOrEqual(measurements.right + 1);
} finally {
await session.cleanup();
}
});
async function measureInlineCode(message: Locator) {
return message.evaluate((element) => {
// Constrain the paragraph to exercise an inline path at a line boundary.
const paragraph = element.querySelector("p")!;
paragraph.style.width = "300px";
const codes = Array.from(paragraph.querySelectorAll("code"), (code) => {
const style = getComputedStyle(code);
const range = document.createRange();
range.selectNodeContents(code);
const rects = Array.from(range.getClientRects());
return {
text: code.textContent,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
backgroundColor: style.backgroundColor,
color: style.color,
anchorColor: code.closest("a") ? getComputedStyle(code.closest("a")!).color : null,
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
rects: rects.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top })),
lines: new Set(rects.map((rect) => rect.top)).size,
right: Math.max(...rects.map((rect) => rect.right)),
};
});
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,
}) => {
@@ -1,5 +1,16 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { killProcessTree } from "../support/helpers/spawn-node";
import {
buildAgentRoute,
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => {
}
});
});
const questionDaemonMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
z.object({
type: z.literal("attempt"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
}),
z.object({
type: z.literal("resolved"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
pendingRequestIds: z.array(z.string()),
}),
z.object({ type: z.literal("error"), error: z.string() }),
]);
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
interface QuestionRetryControl {
attempts: QuestionResponseAttempt[];
resolutions: QuestionResolution[];
releaseFirstResponse(): void;
}
const retryTest = test.extend<{
questionClient: QuestionRetryControl;
}>({
questionClient: async ({ page }, provide, testInfo) => {
const serverDir = path.resolve(__dirname, "../../../server");
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
cwd: serverDir,
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const output: string[] = [];
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const attempts: QuestionResponseAttempt[] = [];
const resolutions: QuestionResolution[] = [];
child.on("message", (message: unknown) => {
const parsed = questionDaemonMessage.parse(message);
switch (parsed.type) {
case "ready":
ready.resolve(parsed);
break;
case "attempt":
attempts.push(parsed);
break;
case "resolved":
resolutions.push(parsed);
break;
case "error":
ready.reject(new Error(parsed.error));
break;
}
});
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
const timeout = setTimeout(
() => ready.reject(new Error("Question daemon startup timed out")),
20_000,
);
try {
const daemon = await ready.promise;
clearTimeout(timeout);
const session = await seedMockAgentWorkspace({
port: daemon.port,
repoPrefix: "question-retry-",
title: "Question response retry",
initialPrompt: "Emit synthetic questions.",
});
try {
await page.goto("/", { waitUntil: "commit" });
const serverId = daemon.serverId;
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "Question retry host",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
waitUntil: "commit",
});
await waitForQuestionPrompt(page, 120_000);
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
} finally {
await session.cleanup();
}
} finally {
clearTimeout(timeout);
await killProcessTree(child);
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("question-daemon", {
body: output.join(""),
contentType: "text/plain",
});
}
}
},
});
retryTest(
"failed question submission preserves answers and allows retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
});
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("progressbar")).toBeVisible();
await expectQuestionPrimaryActionDisabled(page, "Submit");
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
await expect(card.getByRole("tab").first()).toBeDisabled();
await expect(card.getByRole("textbox")).not.toBeEditable();
questionClient.releaseFirstResponse();
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
await expectQuestionDismissEnabled(page);
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "Behind feature flag");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue(
"Preserve my answers after a failed submission.",
);
await submitQuestionAnswers(page);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toMatchObject({
behavior: "allow",
updatedInput: {
answers: {
surface: "App",
rollout: "Behind feature flag",
success: "Preserve my answers after a failed submission.",
},
},
});
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
},
);
retryTest(
"failed question dismissal allows an immediate retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
});
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
await expectQuestionDismissEnabled(page);
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
await expect(card.getByRole("alert")).toBeVisible();
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toEqual({
behavior: "deny",
message: "Dismissed by user",
});
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
},
);
@@ -21,6 +21,15 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Show background",
]);
await closeMenuSheet(page);
await page
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
.click();
@@ -29,6 +29,16 @@ test.describe("Sidebar project filter", () => {
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toBeVisible({ timeout: 30_000 });
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Project",
"Show background",
]);
await closeSidebarDisplayPreferences(page);
await openSidebarProjectFilter(page);
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
await toggleProjectFilter(page, alpha.projectKey);
@@ -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,
}) => {
@@ -0,0 +1,71 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { openLaunchMenu } from "../support/helpers/new-workspace-launch";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Harmless commands: this journey opens the profile menu without launching them.
test.use({
e2eDaemonConfig: {
version: 1,
daemon: {
terminalProfiles: [
{ id: "antigravity", name: "Antigravity artwork", command: "/bin/sh", icon: "agy" },
{ id: "cursor", name: "Cursor artwork", command: "/bin/sh", icon: "cursor" },
{ id: "unknown", name: "Unknown artwork", command: "/bin/sh", icon: "unknown-icon" },
],
},
},
});
function profileIcon(page: Page, name: string) {
return page.getByRole("menuitem", { name: new RegExp(`^${name} artwork`) }).locator("svg");
}
async function expectBundledArtwork(page: Page, name: string, artwork: string) {
const icon = profileIcon(page, name);
await expect(icon).toBeVisible();
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, artwork);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await expect(icon).toHaveAttribute("width", "14");
await expect(icon).toHaveAttribute("height", "14");
const terminalColor = await page
.getByRole("menuitem", { name: "Terminal", exact: true })
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
await expect(icon.locator("path").first()).toHaveCSS("fill", terminalColor);
}
async function expectUnknownArtworkFallback(page: Page) {
const icon = profileIcon(page, "Unknown");
await expect(icon).toBeVisible();
await expect(icon.locator('path[d="M12 8V4H8"]')).toHaveCount(1);
}
test("terminal profiles show bundled artwork while unknown icons keep the Bot fallback", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "terminal-icons-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await openLaunchMenu(page);
await expectBundledArtwork(page, "Antigravity", ACP_PROVIDER_ICON_SVGS.agy);
await expectBundledArtwork(page, "Cursor", ACP_PROVIDER_ICON_SVGS.cursor);
await expectUnknownArtworkFallback(page);
} finally {
await workspace.cleanup();
}
});
@@ -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 () => {
@@ -0,0 +1,18 @@
context platform=ios timeout=60000 retries=0
# Start with an empty composer in an idle chat or new-agent draft on an iPhone with the software keyboard enabled.
# Expand a long draft with the keyboard open; Exit fullscreen must remain reachable.
open sh.paseo.debug
wait "editable=true" 60000
fill "editable=true" "iOS fullscreen composer smoke.\n\nLine 1: Check that the composer keeps this draft when expanded and collapsed.\nLine 2: Check that the composer keeps this draft when expanded and collapsed.\nLine 3: Check that the composer keeps this draft when expanded and collapsed.\nLine 4: Check that the composer keeps this draft when expanded and collapsed.\nLine 5: Check that the composer keeps this draft when expanded and collapsed.\nLine 6: Check that the composer keeps this draft when expanded and collapsed.\nLine 7: Check that the composer keeps this draft when expanded and collapsed.\nLine 8: Check that the composer keeps this draft when expanded and collapsed.\nLine 9: Check that the composer keeps this draft when expanded and collapsed.\nLine 10: Check that the composer keeps this draft when expanded and collapsed.\nLine 11: Check that the composer keeps this draft when expanded and collapsed.\nLine 12: Check that the composer keeps this draft when expanded and collapsed.\nLine 13: Check that the composer keeps this draft when expanded and collapsed.\nLine 14: Check that the composer keeps this draft when expanded and collapsed."
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
@@ -0,0 +1,13 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
# A released drag must not swallow the next tap: navigation closes the sidebar.
wait "label=\"Open menu\"" 5000
close
@@ -52,13 +52,11 @@ export async function expectAcpMarkdown(page: Page, count: number): Promise<void
await expect(messages).toHaveCount(count);
for (let index = 0; index < count; index++) {
const message = messages.nth(index);
await expect(message).toHaveText("•Current temperature: 25°C. Read Paseo docs.");
await expect(message).toHaveText("Current temperature: 25°C. Read Paseo docs.");
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/docs");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveText(
"Current temperature",
);
await expect(message.locator("strong")).toHaveText("Current temperature");
}
}
@@ -575,20 +575,25 @@ export async function holdAssistantStream(page: Page, agentId: string) {
});
});
const forwardUntil = async (done: (text: string) => boolean): Promise<void> => {
while (!done(forwardedText)) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
};
return {
waitForInitialTimeline: () => initialTimeline,
showUntil: (text: string) => forwardUntil((forwarded) => forwarded.includes(text)),
async showThrough(prefix: string): Promise<void> {
while (forwardedText.length < prefix.length) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
await forwardUntil((text) => text.length >= prefix.length);
if (forwardedText !== prefix) {
throw new Error(
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
@@ -26,16 +26,13 @@ export interface AssistantImageFixture {
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
input: { alt: string; fileName: string; width?: number; height?: number },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
const width = input.width ?? 1;
const height = input.height ?? 1;
const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height);
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width };
}
export async function createNearTenMegabyteAssistantPng(
@@ -61,6 +58,7 @@ export async function createNearTenMegabyteAssistantPng(
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
markdown?: string,
): Promise<ArchiveTabAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -69,6 +67,7 @@ export async function createSettledMockAgent(
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
featureValues: markdown === undefined ? undefined : { mockAssistantResponse: markdown },
});
await workspace.client.waitForAgentUpsert(
agent.id,
@@ -124,7 +123,7 @@ export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt }).first();
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
@@ -172,14 +171,14 @@ export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
await expect(page.getByRole("button", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt });
const rendered = page.getByRole("button", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
@@ -253,8 +252,8 @@ async function beginVisibleImageStabilityObservation(
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
const imageVisible = Array.from(document.querySelectorAll("img[alt]")).some(
(element) => element.getAttribute("alt") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
@@ -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 ──────────────────────────────────────────────────
/**
@@ -45,11 +45,8 @@ export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): P
export async function expectUnfinishedBold(page: Page): Promise<void> {
const message = page.getByTestId("assistant-message").last();
const bold = message.locator('[data-paseo-markdown-tag="strong"]');
await expect(bold).toContainText("Bold");
await expect(message.locator(".enrm-text")).toContainText("Bold");
await expect(message).not.toContainText("stays bold");
await expect(bold).toHaveCSS("font-weight", "500");
await expect(message).not.toContainText("*");
}
export async function expectUnfinishedLink(
@@ -61,7 +58,6 @@ export async function expectUnfinishedLink(
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
await expect(message).toContainText("Paseo docs");
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
await expect(message).not.toContainText("[");
await expect(message).not.toContainText("https:");
await captureMarkdown(page, testInfo, "unfinished-link");
}
@@ -95,8 +91,5 @@ async function expectCompletedMarkdown(page: Page): Promise<void> {
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/documentation");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
"font-weight",
"500",
);
await expect(message.locator("strong")).toHaveCSS("font-weight", "700");
}
@@ -1,8 +1,9 @@
import { writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { seedMockAgentWorkspace, openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
import { holdAssistantStream } from "./agent-timeline-gate";
export const scrollCadences = [
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
@@ -44,6 +45,120 @@ interface TimelinePage {
at: number;
}
export async function withStreamingReader(run: (agent: MockAgentWorkspace) => Promise<void>) {
const section = "Read this paragraph while the response continues below. ".repeat(8);
const response = [
"```mermaid\nflowchart LR\n Start --> Review --> Done\n```",
"```mermaid\nflowchart LR\n Nested --> Finished\n```",
...Array.from({ length: 40 }, (_, index) => `## Long section ${index}\n\n${section}`),
"READER-STREAM-SETTLED",
].join("\n\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "held-stream-reader-",
title: "Held streaming paragraph",
featureValues: {
mockStreamingAssistantResponse: response,
mockStreamingAssistantIntervalMs: 1,
},
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
export async function holdParagraphThroughCompletion(page: Page, agent: MockAgentWorkspace) {
const stream = await holdAssistantStream(page, agent.agentId);
await openAgentRoute(page, agent);
await page.getByRole("textbox", { name: "Message agent...", exact: true }).waitFor();
const scroll = page.getByTestId("agent-chat-scroll");
const following = await scroll.evaluateHandle((viewport) => {
const result = { stopped: false, maxGap: 0 };
const frame = () => {
if (result.stopped) return;
result.maxGap = Math.max(
result.maxGap,
viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
);
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
return result;
});
await agent.client.sendAgentMessage(agent.agentId, "Stream the mixed response.");
await stream.showUntil("Long section 12\n");
await page
.getByRole("heading", { name: "Long section 12", exact: true })
.waitFor({ state: "attached" });
// Exercise a reader whose producer has already finished; delivery must be gated.
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByTestId("mermaid-viewport")).toHaveCount(2);
await expect(page.getByTestId("mermaid-viewport").nth(0)).toHaveCSS("opacity", "1");
await expect(page.getByTestId("mermaid-viewport").nth(1)).toHaveCSS("opacity", "1");
const maxFollowGap = await following.evaluate((result) => {
result.stopped = true;
return result.maxGap;
});
const bounds = await scroll.boundingBox();
if (!bounds) throw new Error("Reader viewport is missing");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.wheel(0, -1600);
await page.waitForTimeout(150);
const paragraph = page.getByRole("heading", { name: "Long section 3", exact: true });
await paragraph.waitFor({ state: "attached" });
const recording = await paragraph.evaluateHandle((heading) => {
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
throw new Error("The stream completed before the reader detached");
}
const viewport = document.querySelector<HTMLElement>('[data-testid="agent-chat-scroll"]')!;
const clip = viewport.getBoundingClientRect();
viewport.scrollTop += heading.getBoundingClientRect().y - (clip.top + clip.height / 2);
const baseline = heading.getBoundingClientRect().y;
const initialTextLength = heading.closest('[data-testid="assistant-message"]')!.textContent!
.length;
const result = { maxDrift: 0, retained: true, textGrowth: 0, done: false };
let stableAt = performance.now();
let signature = "";
const frame = (now: number) => {
result.retained &&= heading.isConnected;
const current = [...document.querySelectorAll("h2")].find(
(n) => n.textContent === "Long section 3",
);
const textLength =
current?.closest('[data-testid="assistant-message"]')?.textContent?.length ?? 0;
result.textGrowth = Math.max(result.textGrowth, textLength - initialTextLength);
if (current)
result.maxDrift = Math.max(
result.maxDrift,
Math.abs(current.getBoundingClientRect().y - baseline),
);
const next = `${viewport.scrollHeight}:${viewport.scrollTop}:${current?.getBoundingClientRect().y}`;
if (next !== signature) {
signature = next;
stableAt = now;
}
const complete =
document.body.textContent?.includes("Worked for") &&
document.body.textContent.includes("READER-STREAM-SETTLED") &&
!document.querySelector('[data-testid="turn-working-indicator"]');
if (complete && now - stableAt >= 750) {
result.done = true;
return;
}
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
return result;
});
await expect(
page.getByTestId("scroll-to-bottom-button").filter({ visible: true }).first(),
).toBeVisible();
stream.release();
await page.waitForFunction((result) => result.done, recording);
return { ...(await recording.jsonValue()), maxFollowGap };
}
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
export async function withVariedTimeline(
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
@@ -495,7 +610,7 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, "Show the delayed image");
const image = page.getByRole("img", { name: "Reserved image" }).first();
const image = page.getByRole("button", { name: "Reserved image", exact: true }).first();
await expect(image).toBeVisible();
const before = await image.boundingBox();
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
@@ -512,8 +627,9 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
.toBe(240);
await expect
.poll(async () => {
const box = await image.boundingBox();
return box ? Math.round((box.width / box.height) * 100) : 0;
return image.evaluate((element: HTMLImageElement) =>
Math.round((element.naturalWidth / element.naturalHeight) * 100),
);
})
.toBe(150);
const after = await image.boundingBox();
@@ -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();
}
}
+32 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.2",
"version": "0.12.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -104,7 +104,11 @@
"i18next": "^26.3.0",
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-gfm-table": "^2.0.0",
"mermaid": "^11.16.0",
"micromark-extension-gfm-table": "^2.1.2",
"micromark-util-decode-string": "2.0.1",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -114,15 +118,14 @@
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-uitextview": "^2.2.0",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
@@ -164,6 +167,32 @@
"wrangler": "^4.105.0",
"ws": "^8.20.0"
},
"enriched-markdown": {
"enableMath": false,
"enableCodeHighlight": true,
"codeHighlightLanguages": [
"json",
"html",
"css",
"markdown",
"yaml",
"go",
"java",
"javascript",
"python",
"c",
"rust",
"bash",
"typescript",
"tsx",
"cpp",
"swift",
"php",
"ruby",
"c-sharp"
],
"enableVideo": false
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -0,0 +1,27 @@
// Regenerate from the repo root: node packages/app/scripts/generate-markdown-image-placeholder.mjs
// sharp is already provided by the development dependency tree; it is not used at runtime.
import { readFile, writeFile } from "node:fs/promises";
import sharp from "sharp";
// Lucide Image (ISC), lucide-react-native/dist/esm/icons/image.mjs.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<svg x="293.333333" y="293.333333" width="53.333333" height="53.333333" viewBox="0 0 24 24">
<g fill="none" stroke="#808080" opacity="0.6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</g>
</svg>
</svg>`;
const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
const uri = `data:image/png;base64,${png.toString("base64")}`;
const sourceFile = new URL("../src/components/markdown/source.ts", import.meta.url);
const source = await readFile(sourceFile, "utf8");
await writeFile(
sourceFile,
source.replace(
/export const PENDING_MARKDOWN_IMAGE =\s*"[^"]*";/,
`export const PENDING_MARKDOWN_IMAGE =\n "${uri}";`,
),
);
console.log(`Generated 640×640 Markdown image placeholder: ${png.length} bytes`);
@@ -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");
@@ -65,3 +65,60 @@ it("matches whitespace across text nodes without inspecting hidden link destinat
"world\nline",
]);
});
it("keeps semantic Markdown paragraphs separate while finding inline matches", () => {
const row = content(
"<p>İ😀hello <strong>world</strong>; hello <em>world</em>.</p><p>another paragraph</p>",
);
expect(findRenderedMatches(row, "hello world").map((range) => range.toString())).toEqual([
"hello world",
"hello world",
]);
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"]);
});
@@ -1,10 +1,10 @@
const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"]
.map((tag) => `[data-paseo-markdown-tag="${tag}"]`)
.flatMap((tag) => [tag, `[data-paseo-markdown-tag="${tag}"]`])
.join(",");
const IGNORED =
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
/** The rows of one message, in reading order. Each is one Markdown block. */
/** Rendered rows carrying a source message identity, in reading order. */
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
if (!root) return [];
return Array.from(
@@ -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) {
@@ -98,3 +98,23 @@ function highlightedRow(): string | undefined {
);
return row?.dataset.historyRowId;
}
it("waits for an asynchronous renderer before counting its matches", async () => {
const root = transcript("message-1", ["needle"]);
const content = root.querySelector('[data-message-text="true"]')!;
content.setAttribute("aria-busy", "true");
let settled = false;
const reveal = viewport(root, {})
.reveal("message-1", "needle", 0, new AbortController().signal)
.then((result) => {
settled = true;
return result;
});
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
expect(settled).toBe(false);
content.textContent = "needle and another needle";
content.removeAttribute("aria-busy");
expect(await reveal).toEqual({ occurrence: 0, count: 2 });
});
@@ -21,8 +21,7 @@ export function createFindViewport({
clear() {
CSS.highlights.delete(highlightName);
},
// A message renders as one row per Markdown block, so an occurrence can sit in
// any of them and the chosen one decides where the viewport lands.
// The source message owns navigation; the rendered occurrence owns its position.
reveal(messageId, query, occurrence, signal) {
return new Promise((resolve, reject) => {
let frame = 0;
@@ -61,6 +60,10 @@ export function createFindViewport({
frame = requestAnimationFrame(poll);
return;
}
if (rows.some((row) => row.querySelector('[aria-busy="true"]'))) {
frame = requestAnimationFrame(poll);
return;
}
const ranges = findMessageMatches(getRoot(), messageId, query);
const index =
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
@@ -49,17 +49,6 @@ function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_me
};
}
function assistantBlock(
id: string,
blockIndex: number,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
...assistantMessage(id),
blockGroupId: "response",
blockIndex,
};
}
function timingFor(assistantId: string): Map<string, TurnTiming> {
return new Map([
[
@@ -109,30 +98,4 @@ describe("turn footer spacing", () => {
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each([
["web", false],
["web", true],
["native", false],
["native", true],
] as const)(
"compacts both edges of the final split paragraph on %s when active=%s",
(platform, isTurnActive) => {
const strategy = strategyFor(platform);
const first = assistantBlock("first", 0);
const last = assistantBlock("last", 1);
const layout = layoutStream({
strategy,
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id),
});
const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
},
);
});
+4 -5
View File
@@ -25,7 +25,7 @@ function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "use
function assistantMessage(
id: string,
seed: number,
block?: { groupId: string; index: number },
_block?: { groupId: string; index: number },
turnId?: string,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
@@ -33,7 +33,6 @@ function assistantMessage(
id,
text: id,
timestamp: timestamp(seed),
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
...(turnId ? { turnId } : {}),
};
}
@@ -318,7 +317,7 @@ describe("layoutStream", () => {
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
it("compacts assistant block spacing across the history and live-head boundary", () => {
it("keeps separate messages spaced across history and the live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
@@ -328,8 +327,8 @@ describe("layoutStream", () => {
timingIds: [historyBlock.id, headBlock.id],
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("default");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBottom");
});
it.each(["web", "android"] as const)(
+2 -4
View File
@@ -1,6 +1,6 @@
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership";
@@ -311,10 +311,8 @@ function layoutSegmentItem(
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
const assistantSpacing = getAssistantMessageSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
@@ -1,7 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
@@ -10,9 +8,11 @@ import {
type ToolCallItem,
type UserMessageItem,
} from "@/types/stream";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
const runtime = {
paseo: {},
@@ -123,6 +123,25 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
return [...result.tail, ...result.head];
}
describe("whole-message Markdown presentation", () => {
it("keeps paragraphs, fences and quotes in one progressively updated row", () => {
const present = createStreamPresentation();
const text = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
for (const length of [10, 30, text.length]) {
const source = hydrateStreamState([
{ event: assistant(text.slice(0, length)), timestamp: new Date(1000) },
]);
const result = present({
...presentationOptions,
tail: [],
head: source,
transform: undefined,
});
expect(rows(result)).toEqual(source);
}
});
});
describe("stream presentation through installed plugins", () => {
it("offers every source tool call to an installed transformer in Overview mode", () => {
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
@@ -198,26 +217,17 @@ describe("stream presentation through installed plugins", () => {
{ text: "Header\n\nBody", phase: "streaming" },
]);
expect(rows(harness.render(transform))).toHaveLength(1);
expect(rows(harness.render()).map((item) => item.kind)).toEqual([
"assistant_message",
"assistant_message",
]);
expect(rows(harness.render()).map((item) => item.kind)).toEqual(["assistant_message"]);
});
it("keeps completed native blocks stable while the last block grows and finishes", () => {
it("retains the complete message when streaming finishes", () => {
const harness = streamHarness();
const first = harness.send(assistant("Intro\n\n![Image](image.png)"));
const growing = harness.send(assistant("\n\nAfter\n"));
const last = harness.send(assistant("- item"));
harness.send(assistant("Intro\n\n![Image](image.png)"));
const last = harness.send(assistant("\n\nAfter\n- item"));
const completed = harness.send({ type: "turn_completed", provider: "claude" });
expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]);
expect(first.head).toMatchObject([{ text: "![Image](image.png)", blockIndex: 1 }]);
expect(growing.tail[0]).toBe(first.tail[0]);
expect(growing.tail[1]).toBe(first.head[0]);
expect(last.tail).toBe(growing.tail);
expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]);
expect(completed.tail).toEqual(rows(last));
expect(completed.tail[2]).toBe(last.head[0]);
expect(last.tail).toEqual([]);
expect(last.head).toMatchObject([{ text: "Intro\n\n![Image](image.png)\n\nAfter\n- item" }]);
expect(completed.tail).toEqual(last.head);
expect(completed.head).toEqual([]);
});
@@ -225,8 +235,8 @@ describe("stream presentation through installed plugins", () => {
const harness = streamHarness();
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
const result = harness.send(assistant("\n\nconst b = 2;"));
expect(result.tail).toMatchObject([{ text: "Intro" }]);
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
expect(result.tail).toEqual([]);
expect(result.head).toMatchObject([{ text: "Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
@@ -244,14 +254,13 @@ describe("stream presentation through installed plugins", () => {
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
// never leaves an empty row behind and the reference still resolves.
it("splits an assistant message into the same blocks through history and through the live head", () => {
it("preserves source identity through history and the live head", () => {
const source = hydrateStreamState([
{
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
timestamp: new Date(1000),
},
]);
const messageId = source[0]!.id;
const present = (placement: "tail" | "head") =>
rows(
createStreamPresentation()({
@@ -261,20 +270,7 @@ describe("stream presentation through installed plugins", () => {
transform: undefined,
}),
);
expect(present("tail")).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "[Link][docs]\n\n[docs]: https://example.com",
},
{
id: `${messageId}:block:1`,
blockGroupId: messageId,
blockIndex: 1,
text: "Closing paragraph.",
},
]);
expect(present("tail")).toEqual(source);
const blocks = (items: StreamItem[]) =>
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
@@ -282,7 +278,7 @@ describe("stream presentation through installed plugins", () => {
// Rows are addressed by id from outside presentation, so the same text has to land on
// the same ids whether the reader watched it arrive or opened the chat afterwards.
it("gives a streamed message the block ids its hydrated text would have had", () => {
it("gives a streamed message the same id as its hydrated text", () => {
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
const harness = streamHarness();
for (let end = 8; end < text.length; end += 8) {
@@ -304,7 +300,7 @@ describe("stream presentation through installed plugins", () => {
);
});
it("repoints a block at the canonical source that replaced the streamed one", () => {
it("uses the canonical source that replaced the streamed message", () => {
const harness = streamHarness();
harness.send(assistant("Streamed answer"));
const streamed = rows(harness.render());
@@ -338,15 +334,8 @@ describe("stream presentation through installed plugins", () => {
head: [],
transform: undefined,
});
expect(result.tail).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "Only one paragraph.",
},
]);
expect(result.tail[0]!.id).not.toBe(messageId);
expect(result.tail).toEqual(source);
expect(result.tail[0]!.id).toBe(messageId);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
@@ -563,13 +552,7 @@ describe("timeline presentation", () => {
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
},
];
expect(projectTimelineItems(items)).toEqual(
items.map((item) =>
item.kind === "assistant_message"
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
: item,
),
);
expect(projectTimelineItems(items)).toEqual(items);
expect(projectTimelineItems(items)[0]).toBe(items[0]);
});
+12 -118
View File
@@ -1,13 +1,12 @@
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream";
interface PresentationInput {
tail: StreamItem[];
@@ -17,42 +16,13 @@ interface PresentationInput {
isTurnActive: boolean;
}
function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
return previous.length === next.length && previous.every((item, index) => item === next[index])
? previous
: next;
}
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
* plugin row a timeline transformer projected answers for the item it replaced, whose
* id is what the chat outline and find hold.
*/
/** Find and scrolling address a plugin row by the source it replaces. */
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
}
/**
* A block is reusable only when it still stands for the same source state. Text and
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
* through the row — a reused block carrying a pre-canonical cursor reports a stale
* reading position and weakens fork-boundary resolution.
*/
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
return (
block.id === id &&
block.turnId === source.turnId &&
block.timestamp.getTime() === source.timestamp.getTime() &&
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
block.timelineCursor?.seq === source.timelineCursor?.seq
);
}
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
/** Source messages reach plugins before display projection. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -71,115 +41,39 @@ export function createStreamPresentation() {
return projected;
}
const blocksBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
let liveSources = new Map<string, AssistantMessageItem>();
let historySource: StreamItem[] | undefined;
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
let displayTail: StreamItem[] = [];
let displayHistory: StreamItem[] | undefined;
let promotedRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
/**
* One display row per Markdown block. Each block renders on its own, so a construct
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
* link reference definitions with the block that uses them, but a reference pointing
* at a definition several blocks away renders as literal text. Streamed messages
* always behaved this way; history now matches them.
*/
function nativeBlocks(item: StreamItem): StreamItem[] {
if (item.kind === "user_message") return [presentUserMessage(item)];
if (item.kind !== "assistant_message") return [item];
const cached = blocksBySource.get(item);
if (cached) return cached;
const previousSource = liveSources.get(item.id);
const previous = previousSource && blocksBySource.get(previousSource);
// Parse only the growing last block on append, as the old live reducer did.
// Canonical text replacements are parsed afresh instead of joining fragments.
const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
let prefix: AssistantMessageItem[] = [];
let growingText = item.text;
if (isAppend) {
prefix = previous.slice(0, -1);
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
const parsed = splitMarkdownBlocks(growingText);
// Whitespace-only text has no block, and a message still owns exactly one row.
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
const blocks = [...prefix];
for (const [offset, text] of textBlocks.entries()) {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
blocks.push(existing);
continue;
}
blocks.push({
...item,
id,
blockGroupId: item.id,
blockIndex: index,
text: blockText,
});
}
blocksBySource.set(item, blocks);
return blocks;
function presentRows(item: StreamItem): StreamItem[] {
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
return (input: PresentationInput) => {
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
// One rendering path: history splits the same way the live head does, and
// `blocksBySource` keeps both the live block identities and the split cost
// from being paid again when only the tail's array identity changed.
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
nativeBlocks,
(item) => presentRows(item),
);
historySource = input.tail;
historyTransform = input.transform;
}
const head: StreamItem[] = [];
const promoted: StreamItem[] = [];
const nextLiveSources = new Map<string, AssistantMessageItem>();
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
const blocks = nativeBlocks(item);
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
if (item.kind === "assistant_message" && blocks.length > 1) {
promoted.push(...blocks.slice(0, -1));
head.push(blocks[blocks.length - 1]!);
} else {
head.push(...blocks);
}
const blocks = presentRows(item);
head.push(...blocks);
}
liveSources = nextLiveSources;
const nextPromoted = retainItems(promotedRows, promoted);
if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
displayTail = historyRows;
if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
displayHistory = historyRows;
promotedRows = nextPromoted;
}
if (preparedTail !== displayTail || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, displayTail);
preparedTail = displayTail;
if (preparedTail !== historyRows || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, historyRows);
preparedTail = historyRows;
preparedLevel = input.level;
}
return projectToolCallDetailLevel({
level: input.level,
tail: displayTail,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
+13 -128
View File
@@ -1,135 +1,20 @@
import { describe, expect, it } from "vitest";
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
function assistantBlock(params: {
id: string;
blockGroupId: string;
blockIndex: number;
text?: string;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: params.blockGroupId,
blockIndex: params.blockIndex,
text: params.text ?? "",
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "cmd",
result: null,
status: "executing",
},
},
};
}
describe("isSameAssistantBlockGroup", () => {
it("returns true for two assistant blocks with the same blockGroupId", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
});
it("returns false for blocks from different groups", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
});
it("returns false when one item is not an assistant_message", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const tc = toolCallBlock("tc-1");
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
});
it("returns false for null neighbors", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
});
});
describe("getAssistantBlockSpacing", () => {
it("returns default for non-assistant items", () => {
const tc = toolCallBlock("tc-1");
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
"default",
);
});
it("returns default when no same-group neighbors exist", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(
getAssistantBlockSpacing({
item: a,
aboveItem: null,
belowItem: null,
hasFooterBelow: true,
}),
).toBe("compactBottom");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
"compactTop",
);
});
it("returns compactBottom when the item below is in the same block group", () => {
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
const message: StreamItem = {
kind: "assistant_message",
id: "m",
text: "First\n\nSecond",
timestamp: new Date(0),
};
describe("whole-message spacing", () => {
it("compacts only the gap to its turn footer", () => {
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
expect(getAssistantMessageSpacing({ item: message, hasFooterBelow: true })).toBe(
"compactBottom",
);
});
it("returns compactBoth when both neighbors are in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
"compactBoth",
);
});
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
text: "First paragraph",
});
const headBlock = assistantBlock({
id: "group-1:head",
blockGroupId: "group-1",
blockIndex: 1,
text: "Second paragraph",
});
expect(
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
).toBe("compactBottom");
expect(
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
).toBe("compactTop");
it("keeps separate messages separated", () => {
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
});
});
+5 -30
View File
@@ -1,35 +1,13 @@
import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined;
other: StreamItem | null | undefined;
}): boolean {
return (
params.item?.kind === "assistant_message" &&
params.other?.kind === "assistant_message" &&
params.item.blockGroupId !== undefined &&
params.item.blockGroupId === params.other.blockGroupId
);
}
export function getAssistantBlockSpacing(params: {
export function getAssistantMessageSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom =
params.hasFooterBelow ||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
return "default";
}): "default" | "compactBottom" {
return params.item.kind === "assistant_message" && params.hasFooterBelow
? "compactBottom"
: "default";
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
@@ -62,8 +40,5 @@ export function getGapBetweenStreamItems(
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return SPACING[3];
}
return SPACING[4];
}
@@ -591,8 +591,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
initialNumToRender={12}
windowSize={10}
// A history row can contain an entire long reply. Keep synchronous native
// Markdown measurement bounded while FlatList fills the surrounding window.
initialNumToRender={2}
maxToRenderPerBatch={2}
windowSize={5}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
+18 -4
View File
@@ -307,7 +307,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
imageContext,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -455,7 +454,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const row = segments.historyVirtualized[index];
const measured = row && measuredRowHeights.current.get(row.id);
if (measured && measured.width === contentWidth) return measured.height;
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth }) : 120;
},
observeElementOffset: observeVirtualOffset,
rangeExtractor,
@@ -979,14 +978,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return;
}
scheduleStickToBottom();
// ResizeObserver runs before paint. Following in the next frame exposes
// the newly grown row at the old offset for one painted frame.
forceStickToBottom();
});
observer.observe(scrollContainer);
if (contentNode) {
observer.observe(contentNode);
}
// Paced reveal commits inside a message without rerendering this viewport.
// Observe those commits too, so the next frame sees the grown content at the
// bottom instead of waiting for the resize notification after animation frames.
const contentChanges = new MutationObserver(() => {
if (isActiveRef.current && followOutputRef.current) forceStickToBottom();
});
if (contentNode)
contentChanges.observe(contentNode, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
contentChanges.disconnect();
};
}, [
reconcileReadingPosition,
@@ -994,7 +1004,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
]);
@@ -1152,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
const handle: StreamViewportHandle = {
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
scrollToBottom: () => {
setFollowOutput(true);
cancelPendingStickToBottom();
@@ -1240,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
@@ -1257,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
@@ -1319,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)}
>
+2 -2
View File
@@ -1,4 +1,3 @@
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
@@ -52,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;
@@ -95,7 +96,6 @@ export interface StreamRenderInput {
forwardListContentContainerStyle: StyleProp<ViewStyle>;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
imageContext?: AssistantImageContext;
}
export interface ResolveStreamRenderStrategyInput {
+62 -82
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";
@@ -218,12 +215,13 @@ function renderListEmptyComponent(input: {
);
}
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
// Live and retained rows use the same boundary so completion preserves the message
// subtree, including measured diagrams and selections. FlatList recreates history
// cells with fresh refs and shifted indexes on data changes. This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
const HistoryStreamRow = memo(function HistoryStreamRow({
const StreamRow = memo(function StreamRow({
layoutItem,
renderStreamItem,
}: {
@@ -234,7 +232,7 @@ const HistoryStreamRow = memo(function HistoryStreamRow({
return <>{renderStreamItem(layoutItem)}</>;
});
function renderHistoryStreamItem(input: {
function renderStreamRow(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
@@ -244,7 +242,7 @@ function renderHistoryStreamItem(input: {
return null;
}
return (
<HistoryStreamRow
<StreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
@@ -252,18 +250,6 @@ function renderHistoryStreamItem(input: {
);
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -398,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,
@@ -437,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);
},
);
@@ -530,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);
@@ -1030,7 +1005,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderHistoryRow = useCallback(
(item: StreamItem) =>
renderHistoryStreamItem({
renderStreamRow({
item,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
@@ -1051,7 +1026,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// to be a new object on every text-chunk flush.
const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent(
(item: StreamItem) =>
renderLiveHeadStreamItem({
renderStreamRow({
item,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
@@ -1137,7 +1112,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
imageContext: { serverId: resolvedServerId, workspaceRoot },
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -1543,11 +1517,17 @@ function PermissionRequestCard({
);
if (request.kind === "question") {
const responseFailed =
permissionMutation.isError &&
permissionMutation.variables?.agentId === permission.agentId &&
permissionMutation.variables?.requestId === permission.request.id;
const responseError = responseFailed ? t("message.question.responseFailed") : null;
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
responseError={responseError}
/>
);
}
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
setAssistantMessageHeight,
clearAssistantMessageHeightEstimateCache,
} from "@/utils/assistant-message-height-estimate";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
@@ -161,56 +161,18 @@ describe("estimateStreamItemHeight", () => {
);
});
it("uses local image dimensions only from the row's server and workspace", () => {
clearAssistantImageMetadataCache();
const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" };
setAssistantImageMetadata(
{ source: "portrait.png", ...imageContext },
{ width: 800, height: 1600 },
);
it("uses the renderer's whole-message measurement at the measured width", () => {
clearAssistantMessageHeightEstimateCache();
const item: StreamItem = {
kind: "assistant_message",
id: "local-image",
text: "![Portrait](portrait.png)",
id: "measured",
text: "First paragraph\n\n![Image](image.png)",
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264);
expect(
estimateStreamItemHeight({
item,
contentMaxWidth: 608,
imageContext: { ...imageContext, workspaceRoot: "/workspace/two" },
}),
).toBe(464);
expect(
estimateStreamItemHeight({
item,
contentMaxWidth: 608,
imageContext: { ...imageContext, serverId: "server-two" },
}),
).toBe(464);
});
it("uses cached assistant image metadata when available", () => {
clearAssistantImageMetadataCache();
setAssistantImageMetadata(
{
source: "https://example.com/tall.png",
},
{ width: 800, height: 1600 },
);
const item: StreamItem = {
kind: "assistant_message",
id: "a-image",
text: "Look at this\n\n![Screenshot](https://example.com/tall.png)",
timestamp: createTimestamp(2),
};
expect(
estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }),
).toBeGreaterThan(220);
setAssistantMessageHeight({ markdown: item.text, width: 608, height: 640 });
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608 })).toBe(640);
expect(estimateStreamItemHeight({ item, contentMaxWidth: 320 })).toBe(220);
clearAssistantMessageHeightEstimateCache();
});
});
@@ -1,4 +1,3 @@
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import {
@@ -47,13 +46,11 @@ export interface WebVirtualizedHistoryWindow {
interface StreamItemHeightEstimateInput {
item: StreamItem;
contentMaxWidth: number;
imageContext?: AssistantImageContext;
}
export function estimateStreamItemHeight({
item,
contentMaxWidth,
imageContext,
}: StreamItemHeightEstimateInput): number {
switch (item.kind) {
case "user_message":
@@ -63,7 +60,6 @@ export function estimateStreamItemHeight({
estimateAssistantMessageHeightFromCache({
markdown: item.text,
contentMaxWidth,
imageContext,
}) ?? 220
);
case "tool_call":
@@ -0,0 +1,57 @@
export const ASSISTANT_FILE_EXTENSIONS: ReadonlySet<string> = new Set([
"astro",
"bash",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"cts",
"cxx",
"env",
"fish",
"go",
"gql",
"gradle",
"graphql",
"h",
"hpp",
"htm",
"html",
"ini",
"java",
"js",
"json",
"jsonc",
"jsx",
"kt",
"kts",
"less",
"lock",
"lua",
"md",
"mdx",
"mjs",
"mts",
"php",
"proto",
"py",
"rb",
"rs",
"sass",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"txt",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
@@ -1,9 +1,3 @@
export {
AssistantInlineCodePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
@@ -11,7 +5,10 @@ export {
} from "./parse";
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions } from "./use-file-link";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
export { createInlinePathOpener } from "./path-open";
@@ -1,24 +0,0 @@
import { createContext, useContext } from "react";
import type { TextProps } from "react-native";
// Carries a link's press handler down to the leaf text spans that render its
// label. On iOS an assistant link is a nested UITextView span, and
// react-native-uitextview only attaches onPress to the *string* children it
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
// MarkdownInheritedText spans markdown produces for link text) pass through
// untouched, so an onPress placed on the wrapping span never reaches a tappable
// native node. Threading the handler through context lets each leaf span hand
// onPress to its own string children, where the native tap recognizer can find
// it. Provided only on iOS (Android/web links tap fine via their own paths).
export interface AssistantLinkPress {
onPress: () => void;
accessibilityRole?: TextProps["accessibilityRole"];
}
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
export function useAssistantLinkPress(): AssistantLinkPress | null {
return useContext(AssistantLinkPressContext);
}
@@ -1,213 +0,0 @@
import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownLinkText } from "@/components/markdown/link-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
}
const MARKDOWN_CODE_LINK_DATASET = {
...CODE_SURFACE_DATASET,
...markdownCopyDataSet.code,
} as const;
export function AssistantMarkdownLink({
source,
style,
monoSurface,
children,
}: AssistantMarkdownLinkProps) {
const { target, onHoverIn, onPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
[target, workspaceRoot],
);
const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
);
const unwrapForMarkdownCopy = source.sourceType === "inline-code" || source.markup === "linkify";
if (isNative) {
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
// inside the paragraph's native UITextView, and a plain <Text> nested there
// is not hoisted into a UITextViewChild, so its text is silently dropped
// (the link disappears). The span composes correctly and stays selectable.
//
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
// children it turns into RNUITextViewChild nodes — the element children that
// markdown emits for link text pass through untouched, so an onPress placed
// here never reaches a tappable native node. We thread it down through
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
// own string children, where the native tap recognizer can find it. iOS
// only: Android forwards onPress through nested <Text> already, and web uses
// the <a> path below.
const span = (
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</MarkdownTextSpan>
);
return (
<FileLinkHoverTooltip filePath={tooltipPath}>
{Platform.OS === "ios" ? (
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
) : (
span
)}
</FileLinkHoverTooltip>
);
}
const anchor = (
<a
{...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})}
href={source.href}
title={source.title}
onClickCapture={preventAnchorNavigation}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<MarkdownLinkText
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={style}
onPress={onPress}
onHoverIn={onHoverIn}
>
{children}
</MarkdownLinkText>
</a>
);
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
}
interface AssistantMarkdownCodeLinkProps {
source: AssistantFileLinkSource;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
children: ReactNode;
}
export function AssistantMarkdownCodeLink({
source,
inheritedStyles,
codeInlineStyle,
linkStyle,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
() => [inheritedStyles, codeInlineStyle, linkStyle],
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink source={source} style={style} monoSurface>
{children}
</AssistantMarkdownLink>
);
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
>
{content}
</AssistantMarkdownCodeLink>
);
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow.
display: "inline-flex" as ViewStyle["display"],
};
function FileLinkHoverTooltip({
filePath,
children,
}: {
filePath: string | null;
children: ReactNode;
}) {
if (!isWeb) {
return children;
}
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger>
{filePath ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
</TooltipContent>
) : null}
</Tooltip>
);
}
const LINK_ANCHOR_STYLE: CSSProperties = {
display: "contents",
color: "inherit",
textDecoration: "none",
};
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
event.preventDefault();
}
const styles = StyleSheet.create((theme) => ({
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
+8 -62
View File
@@ -1,3 +1,4 @@
import { ASSISTANT_FILE_EXTENSIONS } from "./extensions";
import { isAbsolutePath } from "@/utils/path";
function safeDecodeURIComponent(value: string): string {
@@ -20,66 +21,10 @@ const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)?
const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/;
const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/;
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
const ASSISTANT_FILE_EXTENSIONS = new Set([
"astro",
"bash",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"cts",
"cxx",
"env",
"fish",
"go",
"gql",
"gradle",
"graphql",
"h",
"hpp",
"htm",
"html",
"ini",
"java",
"js",
"json",
"jsonc",
"jsx",
"kt",
"kts",
"less",
"lock",
"lua",
"md",
"mdx",
"mjs",
"mts",
"php",
"proto",
"py",
"rb",
"rs",
"sass",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"txt",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/** 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
@@ -291,7 +236,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -335,6 +280,7 @@ export function parseAssistantFileLink(
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
explicitLink: options.explicitLink,
});
if (relativeTarget) {
return relativeTarget;
@@ -468,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">;
@@ -494,7 +440,7 @@ function parseLocalPathParts(
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
@@ -515,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;
},
};
}
@@ -14,6 +14,7 @@ import type { InlinePathTarget } from "./parse";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
readFile?: import("@getpaseo/client/internal/daemon-client").DaemonClient["readFile"];
getDirectorySuggestions: GetDirectorySuggestions;
}
@@ -78,10 +79,16 @@ export function AssistantFileLinkResolverProvider({
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
const context = useContext(AssistantFileLinkResolverContext);
if (!context) {
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
}
return context;
return context ?? EMPTY_CONTEXT;
}
export type { AssistantFileLinkContext };
const EMPTY_CONTEXT: AssistantFileLinkResolverContextValue = {
configRef: { current: {} },
getDirectorySuggestions: async () => ({ entries: [], error: null }),
};
export function useAssistantFileLinkEnvironment() {
return useAssistantFileLinkResolverContext().configRef.current;
}
@@ -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" } };
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
@@ -18,6 +19,7 @@ import {
export interface UseFileLinkResult {
target: InlinePathTarget | null;
tooltipPath: string | null;
onHoverIn: () => void;
onPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
@@ -134,7 +136,11 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
return query.data ?? null;
}, [query.data, resolution]);
return useMemo(() => ({ target, onHoverIn, onPress, open }), [target, onHoverIn, onPress, open]);
const tooltipPath = target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null;
return useMemo(
() => ({ target, tooltipPath, onHoverIn, onPress, open }),
[target, tooltipPath, onHoverIn, onPress, open],
);
}
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
@@ -1,120 +0,0 @@
import { describe, expect, it } from "vitest";
import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle";
describe("assistant image lifecycle", () => {
it("keeps preview URL recreation in the public loading state", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:first",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:first",
aspectRatio: 1.5,
});
const recreating = transitionAssistantImageLifecycle(loaded, {
type: "preview_released",
});
expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null });
});
it("publishes loaded only after the recreated URL loads", () => {
const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:recreated",
aspectRatio: 1.5,
});
expect(recreating).toEqual({
status: "loading",
uri: "blob:recreated",
aspectRatio: 1.5,
});
const recreated = transitionAssistantImageLifecycle(recreating, {
type: "image_loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
expect(recreated).toEqual({
status: "loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
});
it("does not reset a loaded image when the same preview is reported again", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:current",
aspectRatio: 1.5,
});
const repeated = transitionAssistantImageLifecycle(loaded, {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.5,
});
expect(repeated).toBe(loaded);
});
it("publishes failed for a terminal image failure on the current URI", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const failed = transitionAssistantImageLifecycle(loading, {
type: "failed",
uri: "blob:current",
message: "Unable to load image preview.",
});
expect(failed).toEqual({
status: "failed",
message: "Unable to load image preview.",
});
});
it("ignores a stale load callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.25,
});
const afterStaleLoad = transitionAssistantImageLifecycle(current, {
type: "image_loaded",
uri: "blob:released",
aspectRatio: 2,
});
expect(afterStaleLoad).toEqual(current);
});
it("ignores a stale error callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const afterStaleError = transitionAssistantImageLifecycle(current, {
type: "failed",
uri: "blob:released",
message: "Image unavailable",
});
expect(afterStaleError).toEqual(current);
});
});
@@ -1,46 +0,0 @@
export type AssistantImageLifecycle =
| { status: "loading"; uri: string | null; aspectRatio: number | null }
| { status: "loaded"; uri: string; aspectRatio: number }
| { status: "failed"; message: string };
export type AssistantImageLifecycleEvent =
| { type: "preview_created"; uri: string; aspectRatio: number | null }
| { type: "preview_released" }
| { type: "image_loaded"; uri: string; aspectRatio: number }
| { type: "failed"; uri: string; message: string };
export function createAssistantImageLifecycle(): AssistantImageLifecycle {
return { status: "loading", uri: null, aspectRatio: null };
}
export function transitionAssistantImageLifecycle(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
if (event.type === "image_loaded") {
if (state.status !== "loading" || state.uri !== event.uri) {
return state;
}
return {
status: "loaded",
uri: event.uri,
aspectRatio: event.aspectRatio,
};
}
if (event.type === "failed") {
if (state.status === "failed" || state.uri !== event.uri) {
return state;
}
return { status: "failed", message: event.message };
}
if (event.type === "preview_created") {
if (state.status === "loaded" && state.uri === event.uri) {
return state;
}
return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio };
}
if (event.type === "preview_released") {
return { status: "loading", uri: null, aspectRatio: null };
}
return state;
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
constructor(private readonly dimensions: { width: number; height: number } | null) {}
read(): { width: number; height: number } | null {
return this.dimensions;
}
}
describe("assistant image load dimensions", () => {
it("prefers native source dimensions", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 640, height: 320 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 640, height: 320 });
});
it("uses browser event dimensions when native dimensions are absent", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 0, height: 0 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 800, height: 400 });
});
it("falls back to the rendered image adapter", () => {
const renderedImage = { id: "rendered-image" };
const dimensions = resolveAssistantImageLoadDimensions({
target: null,
renderedImage,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 900, height: 600 });
});
});
@@ -1,45 +0,0 @@
export interface AssistantImageDimensions {
width: number;
height: number;
}
interface AssistantImageDimensionCandidate {
width?: unknown;
height?: unknown;
}
export interface AssistantImageRenderedDimensionsReader {
read(renderedImage: unknown): AssistantImageDimensions | null;
}
function readPositiveDimensions(
candidate: AssistantImageDimensionCandidate | null | undefined,
): AssistantImageDimensions | null {
if (
typeof candidate?.width !== "number" ||
typeof candidate.height !== "number" ||
candidate.width <= 0 ||
candidate.height <= 0
) {
return null;
}
return { width: candidate.width, height: candidate.height };
}
export function resolveAssistantImageLoadDimensions(input: {
source?: AssistantImageDimensionCandidate | null;
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
renderedImage: unknown;
renderedDimensions: AssistantImageRenderedDimensionsReader;
}): AssistantImageDimensions | null {
const source = readPositiveDimensions(input.source);
if (source) {
return source;
}
const target = readPositiveDimensions({
width: input.target?.naturalWidth,
height: input.target?.naturalHeight,
});
return target ?? input.renderedDimensions.read(input.renderedImage);
}
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { runAssistantImageOperationWithRetry } from "./retry";
describe("assistant image retry", () => {
it("recovers from transient failures while the image remains mounted", async () => {
const waits: number[] = [];
let attempts = 0;
const result = await runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
if (attempts < 3) {
throw new Error("transient");
}
return "loaded";
},
delaysMs: [10, 20, 30],
wait: async (delayMs) => {
waits.push(delayMs);
},
});
expect({ attempts, waits, result }).toEqual({
attempts: 3,
waits: [10, 20],
result: "loaded",
});
});
it("stops retrying after the image is released", async () => {
let stopped = false;
let attempts = 0;
await expect(
runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
throw new Error("transient");
},
delaysMs: [10, 20],
shouldStop: () => stopped,
wait: async () => {
stopped = true;
},
}),
).rejects.toThrow("transient");
expect(attempts).toBe(1);
});
});
-31
View File
@@ -1,31 +0,0 @@
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
export async function runAssistantImageOperationWithRetry<T>(input: {
operation: () => Promise<T>;
delaysMs?: readonly number[];
shouldStop?: () => boolean;
wait?: (delayMs: number) => Promise<void>;
}): Promise<T> {
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
const shouldStop = input.shouldStop ?? (() => false);
const wait =
input.wait ??
(async (delayMs: number) => {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
});
for (let attempt = 0; ; attempt += 1) {
try {
return await input.operation();
} catch (error) {
const delay = delays[attempt];
if (delay === undefined || shouldStop()) {
throw error;
}
await wait(delay);
if (shouldStop()) {
throw error;
}
}
}
}
@@ -1,544 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ImageLoadEvent } from "react-native";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
import {
persistAttachmentFromBytes,
persistAttachmentFromDataUrl,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
import {
createAssistantImageFileAcquisition,
type AssistantImageAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
import {
createAssistantImageLifecycle,
transitionAssistantImageLifecycle,
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
import { runAssistantImageOperationWithRetry } from "./retry";
interface AssistantImageRenderBinding {
uri: string;
onRef: (instance: unknown) => void;
onLoad: (event: ImageLoadEvent) => void;
onError: () => void;
}
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
read(renderedImage) {
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
return null;
}
const image = renderedImage.querySelector("img");
return image && image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: null;
},
};
export type AssistantImageResult =
| {
status: "loading";
binding: AssistantImageRenderBinding | null;
aspectRatio: number | null;
}
| {
status: "loaded";
binding: AssistantImageRenderBinding;
aspectRatio: number;
}
| { status: "failed"; message: string };
interface UseAssistantImageInput {
source: string;
occurrenceKey: string;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}
type PreviewUrlState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; uri: string }
| { status: "failed"; error: unknown };
type AttachmentAcquisitionState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; attachment: AttachmentMetadata }
| { status: "failed"; error: unknown };
interface DataImage {
mimeType: string;
base64: string;
cacheKey: string;
}
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
capacity: 500,
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
});
interface CachedPreviewUrl {
attachment: AttachmentMetadata;
uri: string;
}
const previewUrlCache = createAssistantImageAcquisitionCache<CachedPreviewUrl>({
capacity: 500,
onRetain:
({ attachment, uri }) =>
() => {
void releaseAttachmentPreviewUrl({ attachment, url: uri });
},
});
const LOADED_IMAGE_CACHE_CAPACITY = 500;
const loadedImageCache = new Map<string, number>();
function getLoadedImageAspectRatio(uri: string): number | null {
const aspectRatio = loadedImageCache.get(uri);
if (aspectRatio === undefined) {
return null;
}
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
return aspectRatio;
}
function rememberLoadedImage(uri: string, aspectRatio: number): void {
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) {
return;
}
const leastRecentlyUsedUri = loadedImageCache.keys().next().value;
if (leastRecentlyUsedUri !== undefined) {
loadedImageCache.delete(leastRecentlyUsedUri);
}
}
function useAttachmentAcquisition(
acquisition: AssistantImageAcquisition | null,
): AttachmentAcquisitionState {
const acquisitionKey = acquisition?.key ?? null;
const [entry, setEntry] = useState<{
key: string | null;
state: AttachmentAcquisitionState;
}>(() => {
const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined;
return {
key: acquisitionKey,
state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" },
};
});
useEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
if (!acquisition || !acquisitionKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = attachmentAcquisitionCache.acquireRetained(
acquisitionKey,
acquisition.locate,
);
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: acquisitionKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const attachment = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [acquisition, acquisitionKey]);
if (!acquisitionKey) {
return { status: "waiting" };
}
const cached = attachmentAcquisitionCache.peek(acquisitionKey);
if (cached) {
return { status: "loaded", attachment: cached };
}
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
}
function createDataImageAcquisition(input: {
source: string;
dataImage: DataImage | null;
}): AssistantImageAcquisition | null {
if (!input.dataImage) {
return null;
}
const { dataImage, source } = input;
return {
key: dataImage.cacheKey,
locate: async () =>
await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
contentKey: dataImage.cacheKey,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
}),
};
}
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
const previewKey =
id && storageType && storageKey && mimeType
? `${id}:${storageType}:${storageKey}:${mimeType}`
: null;
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
return {
key: previewKey,
state: { status: "waiting" },
};
});
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
useLayoutEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
const current = getCurrentAttachment();
if (!current || !previewKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
attachment: current,
uri: await resolveAttachmentPreviewUrl(current),
}));
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: previewKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const preview = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: previewKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [getCurrentAttachment, previewKey]);
if (!previewKey) {
return { status: "waiting" };
}
return entry.key === previewKey ? entry.state : { status: "waiting" };
}
function lifecycleReducer(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
return transitionAssistantImageLifecycle(state, event);
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function getAcquisitionFailure(input: {
hasResolution: boolean;
isFileSource: boolean;
isDataImage: boolean;
fileAttachment: AttachmentAcquisitionState;
dataImageAttachment: AttachmentAcquisitionState;
preview: PreviewUrlState;
hasDirectUri: boolean;
fallbackMessage: string;
}): AssistantImageResult | null {
if (!input.hasResolution) {
return { status: "failed", message: input.fallbackMessage };
}
if (input.isFileSource && input.fileAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.fileAttachment.error, input.fallbackMessage),
};
}
if (input.isDataImage && input.dataImageAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage),
};
}
if (!input.hasDirectUri && input.preview.status === "failed") {
return {
status: "failed",
message: errorMessage(input.preview.error, input.fallbackMessage),
};
}
return null;
}
export function useAssistantImage({
source,
occurrenceKey,
client,
workspaceRoot,
serverId,
}: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path) => await client.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null;
return createAssistantImageFileAcquisition({
port,
resolution,
serverId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, resolution, serverId, t]);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
);
const fileAttachment = useAttachmentAcquisition(fileAcquisition);
const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition);
const filePreview = usePreviewUrl(
fileAttachment.status === "loaded" ? fileAttachment.attachment : null,
);
const dataImagePreview = usePreviewUrl(
dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null,
);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const preview = dataImage ? dataImagePreview : filePreview;
const previewUri = preview.status === "loaded" ? preview.uri : null;
const uri = directUri ?? previewUri;
const cachedAspectRatio = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null,
[serverId, source, workspaceRoot],
);
const [lifecycle, dispatchLifecycle] = useReducer(
lifecycleReducer,
uri,
(initialUri): AssistantImageLifecycle => {
if (initialUri) {
const aspectRatio = getLoadedImageAspectRatio(initialUri);
if (aspectRatio !== null) {
return { status: "loaded", uri: initialUri, aspectRatio };
}
}
return createAssistantImageLifecycle();
},
);
const dispatch = useCallback((event: AssistantImageLifecycleEvent) => {
if (event.type === "image_loaded") {
rememberLoadedImage(event.uri, event.aspectRatio);
} else if (event.type === "failed" && event.uri) {
loadedImageCache.delete(event.uri);
}
dispatchLifecycle(event);
}, []);
const renderedImageRef = useRef<unknown>(null);
const handleImageRef = useCallback((instance: unknown) => {
renderedImageRef.current = instance;
}, []);
useEffect(() => {
if (!uri) {
dispatch({ type: "preview_released" });
return;
}
dispatch({
type: "preview_created",
uri,
aspectRatio: cachedAspectRatio,
});
}, [cachedAspectRatio, dispatch, uri]);
const handleImageError = useCallback(() => {
if (uri) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
}
}, [dispatch, t, uri]);
const handleImageLoad = useCallback(
(event: ImageLoadEvent) => {
if (!uri) {
return;
}
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
};
const dimensions = resolveAssistantImageLoadDimensions({
source: nativeEvent.source,
target: nativeEvent.target,
renderedImage: renderedImageRef.current,
renderedDimensions,
});
const metadata = dimensions
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
: null;
const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio;
if (!aspectRatio) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
return;
}
dispatch({ type: "image_loaded", uri, aspectRatio });
},
[cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot],
);
const acquisitionFailure = getAcquisitionFailure({
hasResolution: resolution !== null,
isFileSource: resolution?.kind === "file_rpc",
isDataImage: dataImage !== null,
fileAttachment,
dataImageAttachment,
preview,
hasDirectUri: directUri !== null,
fallbackMessage: t("message.attachments.imagePreviewLoadFailed"),
});
if (acquisitionFailure) {
return acquisitionFailure;
}
const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri;
let binding: AssistantImageRenderBinding | null = null;
if (hasCurrentLifecycleUri && lifecycle.uri) {
binding = {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
};
}
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
return {
status: "loaded",
binding: {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
},
aspectRatio: lifecycle.aspectRatio,
};
}
if (lifecycle.status === "failed") {
return lifecycle;
}
return {
status: "loading",
binding,
aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio,
};
}
@@ -760,3 +760,58 @@ describe("assistant selection copy inside highlighted code", () => {
);
});
});
describe("semantic library output", () => {
it("copies a partial strong span as plain text", () => {
const host = document.createElement("div");
host.innerHTML =
'<div data-testid="assistant-message"><p>Before <strong>bold text</strong> after</p></div>';
document.body.append(host);
const text = host.querySelector("strong")!.firstChild!;
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 4);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
expect(createAssistantSelectionClipboardContent(selection)?.plainText).toBe("old");
expect(createAssistantSelectionClipboardContent(selection)?.html).not.toContain("<strong>");
host.remove();
});
});
it("copies native semantic ordered lists with their original numbers", () => {
const host = document.createElement("div");
host.innerHTML =
'<div data-testid="assistant-message"><ol start="3"><li>Third item</li><li>Fourth item</li></ol></div>';
document.body.append(host);
const selection = selectNodeContents(host.querySelector("ol")!);
const content = createAssistantSelectionClipboardContent(selection);
expect(content?.plainText).toContain("3. Third item");
expect(content?.plainText).toContain("4. Fourth item");
host.remove();
});
it("copies a selection inside semantic inline code without Markdown escaping", () => {
const message = mountFixture();
message.innerHTML = "<p>Run <code>apply_patch</code> now.</p>";
const code = fixtureElement(message, "code");
expect(createAssistantSelectionClipboardContent(selectText(code, 0, 11))?.plainText).toBe(
"apply_patch",
);
});
it("copies a workspace image path instead of its temporary display URL", () => {
const message = mountFixture();
const sources = JSON.stringify({ "blob:preview": "assets/chart.png" });
message.innerHTML = '<div><img src="blob:preview" alt="chart"></div>';
message.firstElementChild!.setAttribute("data-paseo-markdown-image-sources", sources);
const selection = window.getSelection()!;
const range = document.createRange();
range.selectNode(message.querySelector("img")!);
selection.removeAllRanges();
selection.addRange(range);
expect(createAssistantSelectionClipboardContent(selection)?.plainText).toBe(
"![chart](assets/chart.png)",
);
});
@@ -6,6 +6,7 @@ import {
type MarkdownClipboardContent,
} from "@/utils/rich-clipboard";
import {
MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE,
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_ALT_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
@@ -22,9 +23,38 @@ const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
const MESSAGE_ROW_SELECTOR = "[data-message-id]";
const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
const CODE_BLOCK_SELECTOR = `pre, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, code, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
const IMAGE_FRAME_SELECTOR = `img, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
const SEMANTIC_TAGS =
"p,blockquote,br,code,pre,h1,h2,h3,h4,h5,h6,hr,img,li,ol,ul,strong,em,s,del,table,thead,tbody,tr,th,td";
function semanticTag(element: Element): string | null {
return (
element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) ??
(element.matches(SEMANTIC_TAGS) ? element.tagName.toLowerCase() : null)
);
}
function copySemanticAttributes(original: Element, copy: Element): void {
const tag = semanticTag(original);
if (!tag || original.hasAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE)) return;
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, tag);
if (tag === "ol")
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, original.getAttribute("start") ?? "1");
if (tag === "img") {
const url = original.getAttribute("src") ?? "";
const encodedSources = original
.closest(`[${MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE}]`)
?.getAttribute(MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE);
const sources: Record<string, string> = encodedSources ? JSON.parse(encodedSources) : {};
copy.setAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE, sources[url] ?? url);
copy.setAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE, original.getAttribute("alt") ?? "");
}
if (tag === "td" || tag === "th") {
const align = (original as HTMLElement).style.textAlign;
if (align) copy.setAttribute(MARKDOWN_COPY_ALIGN_ATTRIBUTE, align);
}
}
const turndown = new TurndownService({
bulletListMarker: "-",
@@ -247,6 +277,7 @@ function cloneMarkdownSelection(range: Range, message: Element): Node {
while (ancestor && ancestor !== message) {
if (shouldPreserveSemanticElement(range, ancestor)) {
const wrapper = ancestor.cloneNode(false) as Element;
copySemanticAttributes(ancestor, wrapper);
normalizeOrderedListStart(wrapper, ancestor, range);
wrapper.append(selected);
selected = wrapper;
@@ -294,25 +325,26 @@ function nodeAtPath(root: Node, path: number[]): Node {
}
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a, ${SEMANTIC_TAGS}`;
const originals = Array.from(source.querySelectorAll(selector));
const copies = Array.from(clone.querySelectorAll(selector));
originals.forEach((original, index) => {
const copy = copies[index];
if (copy) copySemanticAttributes(original, copy);
if (!copy || shouldPreserveSemanticElement(range, original)) {
if (copy) {
normalizeOrderedListStart(copy, original, range);
}
return;
}
const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
const tag = semanticTag(original);
if (tag && isBlockBoundary(tag)) {
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
return;
}
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (copy.tagName === "A") {
if (copy.matches(SEMANTIC_TAGS) || copy.tagName === "A") {
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
}
});
@@ -322,7 +354,7 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (!range.intersectsNode(element)) {
return false;
}
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
const tag = semanticTag(element);
// An image has no part to select, so touching it selects all of it.
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
@@ -340,8 +372,16 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
);
}
if (tag === "li" && hasSelectedListMarker(range, element)) {
return true;
if (tag === "li") {
if (hasSelectedListMarker(range, element)) return true;
// CSS list markers are outside text nodes. Starting inside a semantic item
// excludes its marker even when all of the label's characters are selected.
if (
element.tagName === "LI" &&
range.startContainer !== element &&
element.contains(range.startContainer)
)
return false;
}
return hasSelectedAllContents(range, element, tag === "li");
}
@@ -358,7 +398,7 @@ function selectionStaysInsideElement(range: Range, element: Element): boolean {
function selectedListItems(list: Element, range: Range): Element[] {
return Array.from(list.children).filter(
(child) =>
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
semanticTag(child) === "li" &&
(child.contains(range.startContainer) ||
child.contains(range.endContainer) ||
hasSelectedAllContents(range, child, true)),
@@ -368,7 +408,7 @@ function selectedListItems(list: Element, range: Range): Element[] {
function hasSelectedListMarker(range: Range, item: Element): boolean {
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
if (!marker) {
return false;
return item.tagName === "LI" && range.startContainer === item && range.startOffset === 0;
}
const markerContents = document.createRange();
@@ -391,18 +431,20 @@ function hasSelectedListMarker(range: Range, item: Element): boolean {
}
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
if (semanticTag(original) !== "ol") {
return;
}
const firstSelectedItem = selectedListItems(original, range)[0];
if (!firstSelectedItem) {
return;
}
const items = Array.from(original.children).filter(
(child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
);
const items = Array.from(original.children).filter((child) => semanticTag(child) === "li");
const omittedItems = items.indexOf(firstSelectedItem);
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
const originalStart = Number(
original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ??
original.getAttribute("start") ??
1,
);
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
}
@@ -19,44 +19,11 @@ export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
export const TRAILING_CODE_LINE_BREAKS = /(\r?\n[ \t]*)+$/;
export const markdownCopyDataSet = {
blockquote: { paseoMarkdownTag: "blockquote" },
br: { paseoMarkdownTag: "br" },
code: { paseoMarkdownTag: "code" },
h1: { paseoMarkdownTag: "h1" },
h2: { paseoMarkdownTag: "h2" },
h3: { paseoMarkdownTag: "h3" },
h4: { paseoMarkdownTag: "h4" },
h5: { paseoMarkdownTag: "h5" },
h6: { paseoMarkdownTag: "h6" },
hr: { paseoMarkdownTag: "hr" },
ignore: { paseoMarkdownIgnore: "true" },
li: { paseoMarkdownTag: "li" },
listMarker: { paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" },
ol: { paseoMarkdownTag: "ol" },
p: { paseoMarkdownTag: "p" },
pre: { paseoMarkdownTag: "pre" },
s: { paseoMarkdownTag: "s" },
strong: { paseoMarkdownTag: "strong" },
em: { paseoMarkdownTag: "em" },
table: { paseoMarkdownTag: "table" },
tbody: { paseoMarkdownTag: "tbody" },
td: { paseoMarkdownTag: "td" },
th: { paseoMarkdownTag: "th" },
thead: { paseoMarkdownTag: "thead" },
tr: { paseoMarkdownTag: "tr" },
ul: { paseoMarkdownTag: "ul" },
unwrap: { paseoMarkdownUnwrap: "true" },
ignore: { paseoMarkdownIgnore: "true" },
} as const;
export type MarkdownCopyInlineTag = "br" | "code" | "em" | "s" | "strong";
export function markdownCopyOrderedListDataSet(start: unknown) {
return {
...markdownCopyDataSet.ol,
paseoMarkdownListStart: String(start ?? 1),
} as const;
}
export function markdownCopyCodeBlockDataSet(language: string | null | undefined) {
const fenceLanguage = language?.trim().split(/\s+/)[0];
return {
@@ -65,25 +32,7 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
} as const;
}
/**
* An image copies as the Markdown it came from. The rendered `img` points at a preview
* URL for data and workspace images, and carries no alt text of its own.
*/
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
return {
paseoMarkdownTag: "img",
paseoMarkdownSrc: source,
...(alt ? { paseoMarkdownAlt: alt } : {}),
} as const;
}
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
const alignment =
typeof style === "string"
? style.match(/(?:^|;)\s*text-align\s*:\s*(left|right|center)/i)?.[1]
: null;
return {
...markdownCopyDataSet[tag],
...(alignment ? { paseoMarkdownAlign: alignment.toLowerCase() } : {}),
} as const;
export const MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE = "data-paseo-markdown-image-sources";
export function markdownCopyImageSourcesDataSet(sources: Record<string, string>) {
return { paseoMarkdownImageSources: JSON.stringify(sources) };
}
@@ -2,6 +2,7 @@ export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
onCaptureAvailabilityChange?(available: boolean): void;
onError?(error: Error): void;
}
@@ -25,3 +26,8 @@ export interface AudioEngine {
clearQueue(): void;
isPlaying(): boolean;
}
export interface AudioEngineOptions {
traceLabel?: string;
captureMode?: "conversation" | "dictation";
}
+18 -3
View File
@@ -1,4 +1,9 @@
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
@@ -8,7 +13,7 @@ import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
options?: AudioEngineOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
@@ -47,6 +52,12 @@ export function createAudioEngine(
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
if (event.data === "capture_paused" || event.data === "capture_resumed") {
if (refs.captureActive) {
callbacks.onCaptureAvailabilityChange?.(event.data === "capture_resumed");
}
return;
}
if (event.data !== "blocked") {
return;
}
@@ -180,7 +191,10 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
const isRecording = native.toggleRecording(true);
const isRecording =
options?.captureMode === "dictation"
? native.startDictation()
: native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
@@ -201,6 +215,7 @@ export function createAudioEngine(
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
callbacks.onCaptureAvailabilityChange?.(true);
releaseSessionIfIdle();
},
+7 -2
View File
@@ -1,6 +1,11 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createPlaybackQueue } from "./playback";
@@ -46,7 +51,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
_options?: AudioEngineOptions,
): AudioEngine {
const refs: {
playbackContext: AudioContext | null;
@@ -0,0 +1,11 @@
import type { CappedAssistantMessage } from "./assistant-message-render-limit.shared";
export {
getUtf8ByteLength,
type CappedAssistantMessage,
} from "./assistant-message-render-limit.shared";
// Native passes a single string to the C++ parser, without the web renderer's
// React-node fan-out. Keep complete messages selectable across all paragraphs.
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
return { text: message, capped: false };
}
@@ -0,0 +1,30 @@
export interface CappedAssistantMessage {
text: string;
capped: boolean;
}
export function getUtf8ByteLength(message: string): number {
let bytes = 0;
for (let index = 0; index < message.length; index += 1) {
const codeUnit = message.charCodeAt(index);
if (codeUnit < 0x80) {
bytes += 1;
} else if (codeUnit < 0x800) {
bytes += 2;
} else if (
codeUnit >= 0xd800 &&
codeUnit <= 0xdbff &&
index + 1 < message.length &&
message.charCodeAt(index + 1) >= 0xdc00 &&
message.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
@@ -56,3 +56,11 @@ describe("assistant message render limit", () => {
});
});
});
it("preserves complete native multi-paragraph messages and the newline-heavy repro", async () => {
const native = await import("./assistant-message-render-limit.native");
const message = "Paragraph.\n\n".repeat(3000) + "Section 60";
expect(native.capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
const lines = "x\n".repeat(16_000) + "HIDDEN_OVERSIZED_MESSAGE_TAIL";
expect(native.capAssistantMessageForRender(lines)).toEqual({ text: lines, capped: false });
});
@@ -1,55 +1 @@
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
export interface CappedAssistantMessage {
text: string;
capped: boolean;
}
export function getUtf8ByteLength(message: string): number {
let bytes = 0;
for (let index = 0; index < message.length; index += 1) {
const codeUnit = message.charCodeAt(index);
if (codeUnit < 0x80) {
bytes += 1;
} else if (codeUnit < 0x800) {
bytes += 2;
} else if (
codeUnit >= 0xd800 &&
codeUnit <= 0xdbff &&
index + 1 < message.length &&
message.charCodeAt(index + 1) >= 0xdc00 &&
message.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
return { text: message, capped: false };
}
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
if (graphemeSegmenter) {
end = graphemeSegmenter.segment(message).containing(end)!.index;
} else {
const finalCodeUnit = message.charCodeAt(end - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
end -= 1;
}
}
return { text: message.slice(0, end), capped: true };
}
export * from "./assistant-message-render-limit.web";
@@ -0,0 +1,30 @@
import type { CappedAssistantMessage } from "./assistant-message-render-limit.shared";
export {
getUtf8ByteLength,
type CappedAssistantMessage,
} from "./assistant-message-render-limit.shared";
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
return { text: message, capped: false };
}
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
if (graphemeSegmenter) {
end = graphemeSegmenter.segment(message).containing(end)!.index;
} else {
const finalCodeUnit = message.charCodeAt(end - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
end -= 1;
}
}
return { text: message.slice(0, end), capped: true };
}
@@ -87,7 +87,9 @@ export function DictationControls({
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
@@ -210,6 +212,11 @@ export function DictationOverlay({
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
@@ -1,7 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import {
Pressable,
View,
Text,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -96,13 +102,13 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
onPointerLeave={handlePointerLeave}
>
{keyedLines ? (
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
<Text style={innerTextStyle} dataSet={markdownCopyDataSet.code}>
{renderCodeSegments(keyedLines)}
</MarkdownTextSpan>
</Text>
) : (
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
<Text style={innerTextStyle} dataSet={markdownCopyDataSet.code}>
{renderedCode}
</MarkdownTextSpan>
</Text>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
@@ -129,9 +135,7 @@ interface TokenSpanProps {
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
return (
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</MarkdownTextSpan>
<Text style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>{token.text}</Text>
);
});
@@ -140,7 +144,7 @@ interface CodeTextSpanProps {
}
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
return <Text>{text}</Text>;
});
interface SplitStyles {
+60 -49
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,
@@ -32,6 +38,7 @@ import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
@@ -61,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 {
@@ -73,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;
@@ -317,13 +327,16 @@ function FooterIconButton({
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
/>
{({ hovered, pressed }) => (
<>
<SidebarFooterIconHighlight visible={pressed} />
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</Pressable>
</TooltipTrigger>
@@ -541,6 +554,11 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
const closeButton = useMemo(
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
[labels.closeSidebar, closeSidebar],
);
const mobileSidebarInsetStyle = useMemo(
() => ({
paddingTop: insetsTop,
@@ -558,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 />
@@ -622,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,
@@ -852,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,

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