Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 244f957b8f test(claude): cover reasoning timeline items for a thinking-enabled agent
Real-provider e2e for #4342. Drives a Claude session through the provider
and asserts the turn emits reasoning timeline items. Passes on main with
Claude Code 2.1.280, so it records the current behaviour rather than a
reproduction of the report.
2026-09-22 22:41:57 +02:00
paseo-ai[bot] bc5bc969c8 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 18:44:27 +00:00
Mohamed Boudra 8186585201 chore(release): cut 0.9.1 2026-09-22 20:34:13 +02:00
Mohamed Boudra eb665a55a8 docs(changelog): add 0.9.1 entry 2026-09-22 20:30:04 +02:00
Mohamed Boudra d22737c77a chore: keep heap snapshots out of git
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
2026-09-22 20:30:04 +02:00
aeb98f813a feat(claude): add Opus 5.5 with its model capabilities (#5200)
feat(claude): add Opus 5.5 with its model capabilities

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

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

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

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

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

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

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

Closes #5073

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #4382

Co-authored-by: therainisme <therainisme@qq.com>
Co-authored-by: trim21 <i@trim21.me>
2026-09-21 11:53:52 +02:00
paseo-ai[bot] d636abd7a4 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-18 09:49:37 +00:00
100 changed files with 2834 additions and 462 deletions
+3
View File
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+11 -5
View File
@@ -1,14 +1,18 @@
# Changelog
## 0.9.0-beta.2 - 2026-09-18
## 0.9.1 - 2026-09-22
### Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed every workspace showing the restart time as its last activity after relaunching ([#5040](https://github.com/getpaseo/paseo/pull/5040))
- Fixed finished chats dropping out of Ready to review after a restart ([#5040](https://github.com/getpaseo/paseo/pull/5040))
- Fixed launch resuming a provider session for every chat ever opened on the host ([#5040](https://github.com/getpaseo/paseo/pull/5040))
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0-beta.1 - 2026-09-17
## 0.9.0 - 2026-09-22
### Added
@@ -80,6 +84,8 @@
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
+17 -3
View File
@@ -29,9 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native
blocks and parses only the growing last block during append. Splitting in the reducer discards
paragraph separators and exposes fragments to plugin callbacks, which need the whole source text.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
+7
View File
@@ -86,6 +86,13 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
first page leaves a chat this client just created looking hydrated but still catching up, which
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
one; disconnect and backgrounding keep it.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-4z83Lg/QGO5amZfxfYjQL5yXjU0GB9jnFjzyZ5pyCPI=
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37262,12 +37262,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/server": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37514,10 +37514,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/relay": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38697,14 +38697,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38714,8 +38714,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38737,7 +38737,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38764,7 +38764,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38982,16 +38982,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/highlight": "0.9.0-beta.2",
"@getpaseo/plugin": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/relay": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39895,7 +39895,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -38,6 +38,7 @@ import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
import {
recordPanelToasts,
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
@@ -1165,8 +1166,11 @@ test.describe("Agent message submission", () => {
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
test("restores a rejected submission and accepts its retry", async ({
@@ -1,4 +1,4 @@
import type { BrowserContext } from "@playwright/test";
import type { BrowserContext, Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
/**
* The rows of one assistant message. A message renders one row per Markdown block, so
* every locator and every text walk below covers the group, not a single block.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
}
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
itemText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.filter({ hasText: itemText })
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
tag: "code" | "strong",
text: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text })
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let textNode = walker.nextNode();
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
for (const [tag, text] of [
["strong", "strong prose"],
["em", "emphasized prose"],
+279 -21
View File
@@ -1,5 +1,10 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -15,19 +20,41 @@ import {
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
const STREAMED_RESPONSE = [
"First paragraph with alpha-needle and pair-needle inside.",
"Second paragraph with beta-needle and pair-needle inside.",
`Third paragraph ${FILLER}`,
].join("\n\n");
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
// One message far taller than the viewport, so its first and last paragraph rows are
// never in the virtualizer's window at the same time.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
}).join("\n\n");
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function searchChat(page: Page, text: string) {
async function openChatFind(page: Page) {
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
await page.keyboard.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
}
async function searchChat(page: Page, text: string) {
await openChatFind(page);
await query(page).fill(text);
}
async function expectHighlight(page: Page, text: string) {
@@ -71,6 +98,15 @@ async function nextMatch(page: Page) {
async function previousMatch(page: Page) {
await query(page).press("Shift+Enter");
}
async function expectFindStatus(page: Page, search: string, matches: string) {
await query(page).fill(search);
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
}
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
await nextMatch(page);
await expect(status(page)).toHaveText(matches);
await expectHighlight(page, highlighted);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(query(page)).toHaveCount(0);
@@ -103,14 +139,14 @@ test("loads older history through prompt navigation and finds repeated rendered
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
await searchChat(page, "hello world");
await expectHighlight(page, "hello world");
await expect(status(page)).toHaveText("1 of 2 in message");
await expect(status(page)).toHaveText("1 of 90");
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
await nextMatch(page);
await expect(status(page)).toHaveText("2 of 2 in message");
await expect(status(page)).toHaveText("2 of 90");
await expectHighlight(page, "hello world");
await previousMatch(page);
await expect(status(page)).toHaveText("1 of 2 in message");
await expect(status(page)).toHaveText("1 of 90");
await query(page).fill("hello & world");
await expectHighlight(page, "hello & world");
await query(page).fill("a.b");
@@ -123,6 +159,65 @@ test("loads older history through prompt navigation and finds repeated rendered
}
});
// The scope of the search is the whole chat, so the counter is the position across
// every message that holds a hit, and Next walks off the end of one into the next.
const ACROSS_PROMPT = "Find scope-needle in this chat";
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
/** The test id of the message that holds the current match, so it can be watched crossing one. */
async function highlightedMessage(page: Page) {
return page.evaluate(() => {
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
}
async function expectHighlightedMessage(page: Page, testId: string) {
await expect.poll(() => highlightedMessage(page)).toBe(testId);
}
test("counts and steps through every match in the chat, not just the selected message", async ({
page,
}, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-across-",
title: "Chat Find across messages",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
// One hit in the prompt and two in the reply: three across the chat.
await searchChat(page, "scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "user-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await expectNextMatch(page, "3 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
await expectNextMatch(page, "1 of 3", "scope-needle");
await expectHighlightedMessage(page, "user-message");
await previousMatch(page);
await expect(status(page)).toHaveText("3 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -136,7 +231,7 @@ test("rejects invisible source matches and ignores a late reply after closing",
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message")).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "hidden-destination");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("first cell second cell");
@@ -166,7 +261,7 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message")).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await gate.drop();
await expectReconnectingToastVisible(page);
await searchChat(page, "hello world");
@@ -186,6 +281,104 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
}
});
interface StreamedReplyChat extends MockAgentWorkspace {
/** Resolves once the streamed reply has finished arriving. */
replyFinished(): Promise<void>;
}
/**
* Opens the chat first, then streams a multi-block reply into it and waits for a
* later block, so the block holding the early needles is no longer the growing row.
*/
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-live-blocks-",
title: "Chat Find live blocks",
featureValues: {
mockStreamingAssistantResponse: STREAMED_RESPONSE,
mockStreamingAssistantIntervalMs: 60,
},
});
try {
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
timeout: 30_000,
});
return {
...agent,
replyFinished: async () => {
await agent.client.waitForFinish(agent.agentId, 60_000);
},
};
} catch (error) {
await agent.cleanup();
throw error;
}
}
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
test.setTimeout(120_000);
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
// The only hit is in the first paragraph, which is no longer the row that grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
/**
* Opens a chat whose newest reply is a single message far taller than the viewport,
* behind enough older turns that the oldest one is not hydrated yet.
*/
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-virtualized-",
title: "Chat Find virtualized message",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
// Every block row of the selected message must be mounted for Find to see it, whatever
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
// from the count and Next walks off to the next message instead.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
test.setTimeout(120_000);
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
page,
}) => {
@@ -194,7 +387,8 @@ test("finds text beyond the normal render cap and restores the cap when Find clo
repoPrefix: "chat-find-cap-",
title: "Chat Find long message",
initialPrompt: "Render a long response",
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}\n\n${tail}` },
// One paragraph, so the needle is past the render cap of the row that holds it.
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
@@ -212,30 +406,94 @@ test("finds text beyond the normal render cap and restores the cap when Find clo
}
});
for (const shortcut of ["Control+f", "Meta+f"]) {
test(`opens and refocuses Find with ${shortcut} from the composer`, async ({
page,
}, testInfo) => {
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-composer-",
title: "Chat Find from composer",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await searchFromComposerWithoutLosingDraft(page, shortcut);
await refocusFindAndReplaceQuery(page, shortcut);
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
await closeFind(page);
await expectCommandCenterOwnsFindShortcut(page, shortcut);
await fillComposerDraft(page, "Draft after closing Find");
await expectComposerDraft(page, "Draft after closing Find");
} finally {
await agent.cleanup();
}
}
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Control+f", testInfo);
});
test.describe("macOS", () => {
test.use({ userAgent: MAC_USER_AGENT });
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Meta+f", testInfo);
});
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
test("leaves Control+f to the composer", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-composer-",
title: "Chat Find from composer",
repoPrefix: "chat-find-mac-control-f-",
title: "Chat Find macOS Control+f",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await searchFromComposerWithoutLosingDraft(page, shortcut);
await refocusFindAndReplaceQuery(page, shortcut);
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
await closeFind(page);
await expectCommandCenterOwnsFindShortcut(page, shortcut);
await fillComposerDraft(page, "Draft after closing Find");
await expectComposerDraft(page, "Draft after closing Find");
await fillComposerDraft(page, "Keep this draft");
await expectComposerFocused(page);
await recordComposerKeydown(page);
await page.keyboard.press("Control+f");
await expect
.poll(() => recordedComposerFindKeydown(page))
.toEqual([{ key: "f", defaultPrevented: false }]);
await expect(query(page)).toHaveCount(0);
await expectComposerFocused(page);
await expectComposerDraft(page, "Keep this draft");
} finally {
await agent.cleanup();
}
});
});
interface RecordedKeydown {
key: string;
defaultPrevented: boolean;
}
/**
* Chat Find listens on `document` in the capture phase, so the composer only ever
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
* on the composer the evidence of who owns the key.
*/
async function recordComposerKeydown(page: Page) {
await composerLocator(page).evaluate((element) => {
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
store.__findKeydown = [];
element.addEventListener("keydown", (event) => {
store.__findKeydown.push({
key: (event as KeyboardEvent).key,
defaultPrevented: event.defaultPrevented,
});
});
});
}
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
const recorded = await page.evaluate(
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
);
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
}
async function openFindFromComposer(page: Page, shortcut: string) {
@@ -1,4 +1,4 @@
import { test } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
@@ -79,6 +79,27 @@ test.describe("Command Center agent controls", () => {
}
});
test("shows an existing agent's only supported thinking level", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-controls-single-thinking-",
title: "Single thinking level",
model: "max-only-thinking-stream",
thinkingOptionId: "max",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
).toHaveAccessibleName("Select thinking option (Max)");
} finally {
await workspace.cleanup();
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
+50 -5
View File
@@ -25,14 +25,21 @@ async function findInSource(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
function querySelection(page: Page) {
return query(page).evaluate((input: HTMLInputElement) => [
input.selectionStart,
input.selectionEnd,
]);
}
/** The query box keeps the caret where the user left it, with nothing reselected. */
async function expectQueryCaretKept(page: Page, text: string) {
await expect.poll(() => querySelection(page)).toEqual([text.length, text.length]);
await expect(query(page)).toHaveValue(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
)
.toEqual([0, text.length]);
await expect.poll(() => querySelection(page)).toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
@@ -428,3 +435,41 @@ test("Go to line keeps its dialog with Find closed and open", async ({
await expect(status(page)).toHaveText("11 matches");
});
});
test.describe("macOS", () => {
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
});
// Control+F moves the caret forward on macOS, so Find must only claim Command+F.
test("opens Find with Meta+f and leaves Control+f to text editing", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-mac-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await source(page).focus();
await source(page).press("Control+f");
await expect(query(page)).toHaveCount(0);
await source(page).press("Meta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await test.step("Control+f inside the query box does not reclaim the field", async () => {
await query(page).press("End");
await query(page).press("Control+f");
await expectQueryCaretKept(page, "needle");
});
await page.screenshot({ path: testInfo.outputPath("mac-find.png") });
await query(page).press("Meta+f");
await expectQuerySelected(page, "needle");
await closeFind(page);
});
});
@@ -18,6 +18,7 @@ export interface MockAgentOptions {
port?: number;
initialPrompt?: string;
model?: string;
thinkingOptionId?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
@@ -43,6 +44,7 @@ export async function seedMockAgentWorkspace(
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "e2e-fast-stream",
thinkingOptionId: options.thinkingOptionId,
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
@@ -111,6 +111,50 @@ export async function expectReconnectingToastGone(
});
}
declare global {
interface Window {
__shownPanelToasts?: string[];
}
}
export interface PanelToastLog {
/** Test ids of every toast that entered the DOM since recording started, in order. */
shown(): Promise<string[]>;
expectNeverShown(testId: string): Promise<void>;
}
/**
* Record every toast that enters the DOM from the next navigation onward. A toast can appear
* and disappear within a few frames, so a settled `toHaveCount(0)` cannot prove one never showed.
*/
export async function recordPanelToasts(page: Page): Promise<PanelToastLog> {
await page.addInitScript(() => {
const shown: string[] = [];
window.__shownPanelToasts = shown;
const record = (node: Element) => {
if (node.getAttribute("role") !== "alert") return;
shown.push(node.getAttribute("data-testid") ?? "");
};
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
record(node);
for (const descendant of node.querySelectorAll('[role="alert"]')) record(descendant);
}
}
}).observe(document, { childList: true, subtree: true });
});
const shown = () => page.evaluate(() => window.__shownPanelToasts ?? []);
return {
shown,
async expectNeverShown(testId: string): Promise<void> {
expect(await shown()).not.toContain(testId);
},
};
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -6,3 +6,7 @@ export function ChatFind({ children }: ChatFindProps) {
export function ChatFindExpansion({ children }: ChatFindExpansionProps) {
return children(false);
}
/** Native has no chat find, so no message is ever selected by one. */
export function useChatFindSelectedMessageId(): string | null {
return null;
}
@@ -11,7 +11,7 @@ import {
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { Button } from "@/components/ui/button";
import { usePaneFocus } from "@/panels/pane-context";
import { useRetainedPanelActive } from "@/components/retained-panel";
@@ -19,11 +19,16 @@ import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
import { ChatFindModel } from "./model";
import { ChatFindModel, type ChatFindFailure } from "./model";
import { createFindViewport } from "./viewport.web";
import type { ChatFindProps, ChatFindExpansionProps } from "./types";
const Expansion = createContext<string | null>(null);
const SelectedMessage = createContext<string | null>(null);
const FAILURE_TEXT: Record<ChatFindFailure, string> = {
connection: "paneFind.connectionFailure",
historyChanged: "paneFind.historyChangedFailure",
reveal: "paneFind.revealFailure",
};
const WIDGET_DATASET = { chatFindWidget: "true" };
const ROOT_STYLE = {
display: "flex",
@@ -34,8 +39,17 @@ const ROOT_STYLE = {
} as const;
let nextHighlightId = 0;
export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) {
return children(useContext(Expansion) === itemId);
/**
* The message chat find has landed on, for surfaces that must keep it addressable:
* the row that lifts its render cap, and the web virtualizer that has to mount every
* block row of it so the whole message can be counted and highlighted.
*/
export function useChatFindSelectedMessageId(): string | null {
return useContext(SelectedMessage);
}
export function ChatFindExpansion({ messageId, children }: ChatFindExpansionProps) {
return children(useChatFindSelectedMessageId() === messageId);
}
export function ChatFind({
@@ -44,15 +58,15 @@ export function ChatFind({
epoch,
items,
viewportRef,
revealLoadedItem,
visibleItemIds,
revealLoadedMessage,
visibleMessageIds,
children,
}: ChatFindProps) {
const { t } = useTranslation();
const widget = useRef<PaneFindHandle>(null);
const root = useRef<HTMLDivElement>(null);
const bindings = useRef({ viewportRef, revealLoadedItem, visibleItemIds });
bindings.current = { viewportRef, revealLoadedItem, visibleItemIds };
const bindings = useRef({ viewportRef, revealLoadedMessage, visibleMessageIds });
bindings.current = { viewportRef, revealLoadedMessage, visibleMessageIds };
const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`);
const { isInteractive } = usePaneFocus();
const active = useRetainedPanelActive();
@@ -92,12 +106,7 @@ export function ChatFind({
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
model.open();
widget.current?.focus();
@@ -117,12 +126,12 @@ export function ChatFind({
else if (state.phase === "error") status = t("paneFind.failed");
else if (state.query.trim())
status = state.count
? t("paneFind.chatPosition", { current: state.occurrence + 1, total: state.count })
? t("paneFind.position", { current: state.occurrence + 1, total: state.count })
: t("paneFind.noMatches");
return (
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
<style>{`::highlight(${highlightName}) { background-color: ${styles.highlight.backgroundColor}; color: ${styles.highlight.color}; }`}</style>
<Expansion.Provider value={state.selectedItemId}>{children}</Expansion.Provider>
<SelectedMessage.Provider value={state.selectedItemId}>{children}</SelectedMessage.Provider>
{state.open && (
<View style={styles.overlay} dataSet={WIDGET_DATASET}>
<PaneFind
@@ -135,9 +144,9 @@ export function ChatFind({
onPrevious={model.previous}
onClose={close}
/>
{state.error && (
{state.failure && (
<View style={styles.error}>
<Text style={styles.errorText}>{t("paneFind.searchFailed")}</Text>
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
<Button size="xs" variant="ghost" onPress={model.retry}>
{t("paneFind.retry")}
</Button>
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { hydrateStreamState, type StreamItem } from "@/types/stream";
import { ChatFindModel, type ChatFindOperations } from "./model";
function response(locations: Array<{ seq: number; role: "user" | "assistant" }>) {
function response(locations: Array<{ seq: number; role: "user" | "assistant"; count?: number }>) {
return {
agentId: "agent",
requestId: "request",
@@ -159,11 +159,60 @@ it("retains a failed search for retry and distinguishes failure from zero matche
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
expect(model.getSnapshot().error).toBe("Host disconnected");
expect(model.getSnapshot().failure).toBe("connection");
disconnected = false;
model.retry();
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ count: 0, error: null });
expect(model.getSnapshot()).toMatchObject({ count: 0, failure: null });
} finally {
model.close();
}
});
// `load` is a host RPC like `search`, so its failure is a connection failure and the
// widget must say so rather than blaming the reveal.
it("reports a failed timeline load as a connection failure", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {
throw new Error("Host disconnected");
},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", []);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("error");
expect(model.getSnapshot().failure).toBe("connection");
} finally {
model.close();
}
});
it("reports a search result that outlived its epoch as a changed history", async () => {
const model = new ChatFindModel({
async search() {
return response([{ seq: 1, role: "assistant" }]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch-2", [message(1)]);
expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" });
} finally {
model.close();
}
@@ -187,7 +236,77 @@ it("can load a search location before the initial chat history has arrived", asy
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null });
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, failure: null });
} finally {
model.close();
}
});
// The scope of the search is the whole chat, so the position spans every located
// message: the host's estimate until a message is on screen, the rendered count after.
it("reports the position across the whole chat and corrects it with rendered counts", async () => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant", count: 2 },
{ seq: 2, role: "assistant", count: 3 },
]);
},
async load() {},
async reveal(id, _query, occurrence) {
const count = id === "row-1" ? 2 : 1;
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 5 });
model.next();
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
expect(model.getSnapshot().count).toBe(5);
model.next();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
model.previous();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-1");
expect(model.getSnapshot()).toMatchObject({ occurrence: 1, count: 3 });
} finally {
model.close();
}
});
// COMPAT(timelineSearchCount): a host that reports no counts is worth one match per
// located message until a reveal replaces the estimate with the rendered count.
it("counts one match per message from a host that reports no counts", async () => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal(id, _query, occurrence) {
const count = id === "row-1" ? 2 : 1;
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)]);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 3 });
model.next();
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
model.next();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
} finally {
model.close();
}
@@ -1,26 +1,59 @@
import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client";
import type { StreamItem } from "@/types/stream";
type Location = AgentTimelineSearchPayload["locations"][number];
/**
* A message the host says contains the query. `count` is the host's estimate of its
* occurrences until a reveal replaces it with the rendered count, so the whole-chat
* position the widget shows is exact for every message that has been on screen. The
* wire field is optional; the search boundary fills it in, so the interior trusts it.
*/
type Location = Omit<AgentTimelineSearchPayload["locations"][number], "count"> & {
count: number;
};
interface Target {
id: string;
seq: number;
before: number;
}
/**
* Why a search stopped, so the widget can say something true. `connection` is the
* host call itself failing, `historyChanged` is the timeline moving under a result
* set, and `reveal` is a located match that never made it onto the screen.
*/
export type ChatFindFailure = "connection" | "historyChanged" | "reveal";
class ChatFindFailureError extends Error {
constructor(
readonly failure: ChatFindFailure,
options?: { cause: unknown },
) {
super(`Chat find failed: ${failure}`, options);
}
}
function tagged<T>(failure: ChatFindFailure, operation: Promise<T>): Promise<T> {
return operation.catch((cause: unknown) => {
throw cause instanceof ChatFindFailureError
? cause
: new ChatFindFailureError(failure, { cause });
});
}
interface Snapshot {
open: boolean;
query: string;
phase: "idle" | "searching" | "loading" | "ready" | "error";
selectedItemId: string | null;
/** Position across the whole chat: the search scope, not the selected message. */
occurrence: number;
count: number;
error: string | null;
failure: ChatFindFailure | null;
}
export interface ChatFindOperations {
search(query: string, cursor?: number): Promise<AgentTimelineSearchPayload>;
load(epoch: string, seq: number): Promise<unknown>;
reveal(
itemId: string,
messageId: string,
query: string,
occurrence: number,
signal: AbortSignal,
@@ -36,7 +69,7 @@ export class ChatFindModel {
selectedItemId: null,
occurrence: 0,
count: 0,
error: null,
failure: null,
};
private listeners = new Set<() => void>();
private historyListeners = new Set<() => void>();
@@ -46,6 +79,7 @@ export class ChatFindModel {
private locations: Location[] = [];
private resolved = new Map<Location, Target>();
private current = -1;
private inMessage = { occurrence: 0, count: 0 };
private abort = new AbortController();
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(private operations: ChatFindOperations) {}
@@ -73,7 +107,7 @@ export class ChatFindModel {
};
readonly close = () => {
this.cancel();
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null });
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, failure: null });
};
readonly setQuery = (query: string) => {
this.cancel();
@@ -81,13 +115,14 @@ export class ChatFindModel {
this.resultEpoch = null;
this.resolved.clear();
this.current = -1;
this.inMessage = { occurrence: 0, count: 0 };
this.publish({
query,
phase: query.trim() ? "searching" : "idle",
selectedItemId: null,
occurrence: 0,
count: 0,
error: null,
failure: null,
});
if (!query.trim()) return;
const signal = this.abort.signal;
@@ -102,20 +137,30 @@ export class ChatFindModel {
this.publish({
phase: "error",
selectedItemId: null,
error: error instanceof Error ? error.message : String(error),
// Every operation call site is tagged, so an untagged failure is a bug here
// rather than a known outcome; `connection` is the least misleading thing to
// say about one, and its copy already asks the reader to check and retry.
failure: error instanceof ChatFindFailureError ? error.failure : "connection",
});
}
private async search(signal: AbortSignal) {
try {
let cursor: number | undefined;
do {
const result = await this.operations.search(this.state.query, cursor);
const result = await tagged("connection", this.operations.search(this.state.query, cursor));
if (signal.aborted) return;
const historyChanged = this.epoch !== null && result.epoch !== this.epoch;
const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch;
if (historyChanged || searchChanged) throw new Error("History changed; search again");
if (historyChanged || searchChanged) throw new ChatFindFailureError("historyChanged");
this.resultEpoch = result.epoch;
this.locations.push(...result.locations);
this.locations.push(
...result.locations.map((location) => ({
seq: location.seq,
role: location.role,
// COMPAT(timelineSearchCount): hosts before v0.9.0 send no count; remove after 2027-09-22.
count: location.count ?? 1,
})),
);
cursor = result.nextCursor ?? undefined;
} while (cursor !== undefined);
await this.navigate(0, 1, signal);
@@ -130,7 +175,7 @@ export class ChatFindModel {
epoch !== this.resultEpoch &&
this.state.open
) {
this.fail(new Error("History changed; search again"), this.abort.signal);
this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal);
}
this.epoch = epoch;
this.items = items;
@@ -159,7 +204,7 @@ export class ChatFindModel {
this.historyListeners.delete(check);
signal.removeEventListener("abort", cancelled);
if (target) resolve(target);
else reject(new Error("Could not load this search location; retry"));
else reject(new ChatFindFailureError("reveal"));
};
const check = () => {
const target = this.target(location, false);
@@ -176,7 +221,7 @@ export class ChatFindModel {
private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) {
const epoch = this.resultEpoch;
if (epoch === null) return;
this.publish({ phase: "loading", error: null });
this.publish({ phase: "loading", failure: null });
let remaining = this.locations.length;
while (remaining-- > 0 && this.locations.length) {
index = (index + this.locations.length) % this.locations.length;
@@ -187,7 +232,7 @@ export class ChatFindModel {
this.items.some((item) => item.id === cached.id && item.timelineCursor?.seq === cached.seq);
let target = stillLoaded ? cached : this.target(location, true);
if (!target) {
await this.operations.load(epoch, location.seq);
await tagged("connection", this.operations.load(epoch, location.seq));
if (signal.aborted) return;
target = await this.waitForTarget(location, signal);
}
@@ -204,16 +249,14 @@ export class ChatFindModel {
);
index = this.locations.indexOf(location);
this.publish({ selectedItemId: target.id });
const result = await this.operations.reveal(
target.id,
this.state.query,
direction === 1 ? 0 : -1,
signal,
const result = await tagged(
"reveal",
this.operations.reveal(target.id, this.state.query, direction === 1 ? 0 : -1, signal),
);
if (signal.aborted) return;
if (result.count) {
this.current = index;
this.publish({ phase: "ready", ...result });
this.publish({ phase: "ready", ...this.position(location, result) });
return;
}
this.locations.splice(index, 1);
@@ -222,23 +265,32 @@ export class ChatFindModel {
this.operations.clear();
this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 });
}
private position(location: Location, revealed: { occurrence: number; count: number }) {
this.inMessage = revealed;
location.count = revealed.count;
let before = 0;
let total = 0;
for (const candidate of this.locations) {
if (candidate === location) before = total;
total += candidate.count;
}
return { occurrence: before + revealed.occurrence, count: total };
}
private async move(direction: 1 | -1) {
if (this.state.phase !== "ready" || !this.state.count) return;
this.cancel();
const signal = this.abort.signal;
try {
const occurrence = this.state.occurrence + direction;
if (occurrence >= 0 && occurrence < this.state.count && this.state.selectedItemId) {
const occurrence = this.inMessage.occurrence + direction;
if (occurrence >= 0 && occurrence < this.inMessage.count && this.state.selectedItemId) {
this.publish({ phase: "loading" });
const result = await this.operations.reveal(
this.state.selectedItemId,
this.state.query,
occurrence,
signal,
const result = await tagged(
"reveal",
this.operations.reveal(this.state.selectedItemId, this.state.query, occurrence, signal),
);
if (signal.aborted) return;
if (result.count) {
this.publish({ phase: "ready", ...result });
this.publish({ phase: "ready", ...this.position(this.locations[this.current]!, result) });
return;
}
}
@@ -1,5 +1,5 @@
import { afterEach, expect, it } from "vitest";
import { findRenderedMatches } from "./ranges.web";
import { findMessageMatches, findRenderedMatches } from "./ranges.web";
afterEach(() => {
document.body.replaceChildren();
@@ -10,6 +10,36 @@ function content(html: string) {
document.body.append(row);
return row;
}
function message(messageId: string, ...blocks: string[]) {
const root = document.createElement("div");
for (const block of blocks) {
const row = document.createElement("div");
row.dataset.messageId = messageId;
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${block}</div></div>`;
root.append(row);
}
document.body.append(root);
return root;
}
it("collects a message's occurrences across its block rows in row order", () => {
const root = message("message-1", "alpha one", "beta", "alpha two");
const other = document.createElement("div");
other.dataset.messageId = "message-2";
other.innerHTML =
'<div data-message-text="true"><div data-paseo-markdown-tag="p">alpha three</div></div>';
root.append(other);
expect(findMessageMatches(root, "message-1", "alpha").map((range) => range.toString())).toEqual([
"alpha",
"alpha",
]);
expect(
findMessageMatches(root, "message-1", "alpha").map(
(range) => range.startContainer.parentElement?.textContent,
),
).toEqual(["alpha one", "alpha two"]);
expect(findMessageMatches(root, "message-2", "alpha")).toHaveLength(1);
expect(findMessageMatches(null, "message-1", "alpha")).toEqual([]);
});
it("finds each actual occurrence across inline formatting with original Unicode offsets", () => {
const row = content(
'<div data-paseo-markdown-tag="p">İ😀hello <strong>world</strong> then <em>hello</em> world</div>',
@@ -4,6 +4,23 @@ const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"
const IGNORED =
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
/** The rows of one message, in reading order. Each is one Markdown block. */
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`),
);
}
/** Occurrences in one message, ordered by row and then by position inside the row. */
export function findMessageMatches(
root: HTMLElement | null,
messageId: string,
query: string,
): Range[] {
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
}
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
@@ -8,11 +8,11 @@ export interface ChatFindProps {
epoch: string | null;
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedItem(itemId: string): boolean;
visibleItemIds: ReadonlySet<string>;
revealLoadedMessage(messageId: string): boolean;
visibleMessageIds: ReadonlySet<string>;
children: ReactNode;
}
export interface ChatFindExpansionProps {
itemId: string;
messageId: string;
children(renderFullContent: boolean): ReactNode;
}
@@ -0,0 +1,100 @@
import { afterEach, expect, it } from "vitest";
import { createFindViewport } from "./viewport.web";
import type { ChatFindProps } from "./types";
import type { StreamViewportHandle } from "../strategy";
interface Capture {
occurrence?: { signal: AbortSignal; targetTop(): number | null };
}
function transcript(messageId: string, blocks: string[]): HTMLElement {
const root = document.createElement("div");
for (const [index, text] of blocks.entries()) {
const row = document.createElement("div");
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
row.setAttribute("data-message-id", messageId);
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${text}</div></div>`;
root.append(row);
}
document.body.append(root);
return root;
}
function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
};
const bindings: Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds"> =
{
viewportRef: { current: handle },
revealLoadedMessage: () => false,
visibleMessageIds: new Set(["message-1"]),
};
return createFindViewport({
getBindings: () => bindings,
getRoot: () => root,
highlightName: "paseo-chat-find-test",
});
}
afterEach(() => {
CSS.highlights.delete("paseo-chat-find-test");
document.body.replaceChildren();
});
it("counts occurrences across every block row of the message", async () => {
const root = transcript("message-1", ["needle one needle", "no hits", "needle three"]);
const capture: Capture = {};
const found = await viewport(root, capture).reveal(
"message-1",
"needle",
2,
new AbortController().signal,
);
expect(found).toEqual({ occurrence: 2, count: 3 });
});
// The settle loop asks for the target position every frame while the viewport moves,
// and rows mount and unmount as it does. A flat index into the message would name a
// different occurrence each time the row set changed; the chosen row owns it instead.
it("keeps the chosen occurrence when an earlier row unmounts mid-scroll", async () => {
const root = transcript("message-1", ["needle one needle", "needle three"]);
const capture: Capture = {};
const found = await viewport(root, capture).reveal(
"message-1",
"needle",
2,
new AbortController().signal,
);
expect(found).toEqual({ occurrence: 2, count: 3 });
const settled = capture.occurrence?.targetTop();
expect(settled).toBeTypeOf("number");
expect(highlightedText()).toEqual(["needle"]);
expect(highlightedRow()).toBe("message-1:block:1");
root.firstElementChild?.remove();
expect(capture.occurrence?.targetTop()).toBeTypeOf("number");
expect(highlightedRow()).toBe("message-1:block:1");
});
function highlightedRanges(): Range[] {
const highlight = CSS.highlights.get("paseo-chat-find-test");
return highlight ? Array.from(highlight).filter((range) => range instanceof Range) : [];
}
function highlightedText(): string[] {
return highlightedRanges().map((range) => range.toString());
}
function highlightedRow(): string | undefined {
const row =
highlightedRanges()[0]?.startContainer.parentElement?.closest<HTMLElement>(
"[data-history-row-id]",
);
return row?.dataset.historyRowId;
}
@@ -1,12 +1,17 @@
import type { ChatFindOperations } from "./model";
import type { ChatFindProps } from "./types";
import { findRenderedMatches } from "./ranges.web";
import { findMessageMatches, findMessageRows, findRenderedMatches } from "./ranges.web";
interface ViewportInput {
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedItem" | "visibleItemIds">;
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds">;
getRoot(): HTMLElement | null;
highlightName: string;
}
function rowOf(range: Range): HTMLElement | null {
return range.startContainer.parentElement?.closest<HTMLElement>("[data-history-row-id]") ?? null;
}
export function createFindViewport({
getBindings,
getRoot,
@@ -16,7 +21,9 @@ export function createFindViewport({
clear() {
CSS.highlights.delete(highlightName);
},
reveal(itemId, query, occurrence, signal) {
// A message renders as one row per Markdown block, so an occurrence can sit in
// any of them and the chosen one decides where the viewport lands.
reveal(messageId, query, occurrence, signal) {
return new Promise((resolve, reject) => {
let frame = 0;
let started = false;
@@ -40,23 +47,21 @@ export function createFindViewport({
return;
}
const current = getBindings();
if (!current.visibleItemIds.has(itemId)) {
current.revealLoadedItem(itemId);
if (!current.visibleMessageIds.has(messageId)) {
current.revealLoadedMessage(messageId);
frame = requestAnimationFrame(poll);
return;
}
if (!started) {
current.viewportRef.current?.scrollToMessage?.(itemId);
current.viewportRef.current?.scrollToMessage?.(messageId);
started = true;
}
const row = getRoot()?.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
);
if (!row || !row.getBoundingClientRect().height) {
const rows = findMessageRows(getRoot(), messageId);
if (!rows.some((row) => row.getBoundingClientRect().height)) {
frame = requestAnimationFrame(poll);
return;
}
const ranges = findRenderedMatches(row, query);
const ranges = findMessageMatches(getRoot(), messageId, query);
const index =
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
const range = ranges[index];
@@ -65,10 +70,18 @@ export function createFindViewport({
resolve({ occurrence: 0, count: 0 });
return;
}
current.viewportRef.current?.scrollToMessage?.(itemId, {
// The settle loop re-resolves the range every frame because scrolling
// invalidates it. Re-counting the whole message would let a row mounting or
// unmounting mid-scroll shift which occurrence a fixed index names, so the
// occurrence is pinned to its own row for the rest of this reveal.
const row = rowOf(range);
const indexInRow = ranges
.slice(0, index)
.filter((earlier) => rowOf(earlier) === row).length;
current.viewportRef.current?.scrollToMessage?.(messageId, {
signal,
targetTop(currentRow) {
const selected = findRenderedMatches(currentRow, query)[index];
targetTop() {
const selected = row ? findRenderedMatches(row, query)[indexInRow] : undefined;
if (!selected) return null;
CSS.highlights.set(highlightName, new Highlight(selected));
const widget = getRoot()?.querySelector<HTMLElement>(
@@ -153,7 +153,7 @@ describe("useChatOutline", () => {
timelineEpoch,
enabled,
viewportRef,
visibleItemIds: new Set(),
visibleMessageIds: new Set(),
onJumpError: vi.fn(),
}),
{
@@ -236,7 +236,7 @@ describe("useChatOutline", () => {
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
});
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const revealLoadedMessage = vi.fn(() => true);
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
@@ -253,7 +253,7 @@ describe("useChatOutline", () => {
},
];
const { result, rerender } = renderHook(
({ visibleItemIds }) =>
({ visibleMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -263,18 +263,18 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
visibleMessageIds,
revealLoadedMessage,
}),
{ initialProps: { visibleItemIds: new Set<string>() } },
{ initialProps: { visibleMessageIds: new Set<string>() } },
);
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
await act(async () => result.current.jumpToPrompt(1));
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ visibleItemIds: new Set(["older-prompt"]) });
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
});
@@ -286,7 +286,7 @@ describe("useChatOutline", () => {
const fetch = deferred<void>();
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
const scrollToMessage = vi.fn();
const revealLoadedItem = vi.fn(() => true);
const revealLoadedMessage = vi.fn(() => true);
const viewportRef = {
current: {
scrollToBottom: vi.fn(),
@@ -302,7 +302,7 @@ describe("useChatOutline", () => {
timelineCursor: { epoch: "epoch-1", seq: 1 },
};
const { result, rerender } = renderHook(
({ tail, visibleItemIds }) =>
({ tail, visibleMessageIds }) =>
useChatOutline({
agentId: "agent-1",
serverId: "server-1",
@@ -312,13 +312,13 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef,
onJumpError: vi.fn(),
visibleItemIds,
revealLoadedItem,
visibleMessageIds,
revealLoadedMessage,
}),
{
initialProps: {
tail: [] as (typeof fetchedPrompt)[],
visibleItemIds: new Set<string>(),
visibleMessageIds: new Set<string>(),
},
},
);
@@ -327,11 +327,11 @@ describe("useChatOutline", () => {
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
expect(scrollToMessage).not.toHaveBeenCalled();
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
await act(async () => fetch.resolve());
@@ -355,8 +355,8 @@ describe("useChatOutline", () => {
timestamp: new Date(2),
timelineCursor: { epoch: "epoch-1", seq: 2 },
};
const revealLoadedItem = vi.fn();
revealLoadedItem.mockReturnValue(false);
const revealLoadedMessage = vi.fn();
revealLoadedMessage.mockReturnValue(false);
const { result } = renderHook(() =>
useChatOutline({
agentId: "agent-1",
@@ -367,8 +367,8 @@ describe("useChatOutline", () => {
enabled: true,
viewportRef: { current: viewport },
onJumpError: vi.fn(),
visibleItemIds: new Set([livePrompt.id]),
revealLoadedItem,
visibleMessageIds: new Set([livePrompt.id]),
revealLoadedMessage,
}),
);
@@ -33,8 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
visibleItemIds?: ReadonlySet<string>;
revealLoadedItem?: (itemId: string) => boolean;
visibleMessageIds?: ReadonlySet<string>;
revealLoadedMessage?: (messageId: string) => boolean;
}
export interface ChatOutline {
@@ -53,8 +53,8 @@ export function useChatOutline({
enabled,
viewportRef,
onJumpError,
visibleItemIds,
revealLoadedItem,
visibleMessageIds,
revealLoadedMessage,
}: UseChatOutlineInput): ChatOutline {
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
@@ -136,8 +136,8 @@ export function useChatOutline({
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
if (target) {
if (pendingJump.hasScrolled) return;
if (visibleItemIds?.has(target.id) === false) {
revealLoadedItem?.(target.id);
if (visibleMessageIds?.has(target.id) === false) {
revealLoadedMessage?.(target.id);
return;
}
viewportRef.current?.scrollToMessage?.(target.id);
@@ -148,7 +148,7 @@ export function useChatOutline({
return;
}
if (pendingJump.fetchSettled) setPendingJump(null);
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
const jumpToPrompt = useCallback(
(seq: number) => {
@@ -156,7 +156,7 @@ export function useChatOutline({
setPendingJump(null);
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
if (loaded) {
if (revealLoadedItem?.(loaded.id)) {
if (revealLoadedMessage?.(loaded.id)) {
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
return;
@@ -180,7 +180,7 @@ export function useChatOutline({
});
});
},
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
);
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
@@ -210,21 +210,113 @@ describe("stream presentation through installed plugins", () => {
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
it("leaves fetched native Markdown intact, including cross-paragraph references", () => {
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
// never leaves an empty row behind and the reference still resolves.
it("splits an assistant message into the same blocks through history and through the live head", () => {
const source = hydrateStreamState([
{
event: assistant("[Link][docs]\n\n[docs]: https://example.com"),
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
timestamp: new Date(1000),
},
]);
const messageId = source[0]!.id;
const present = (placement: "tail" | "head") =>
rows(
createStreamPresentation()({
...presentationOptions,
tail: placement === "tail" ? source : [],
head: placement === "head" ? source : [],
transform: undefined,
}),
);
expect(present("tail")).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "[Link][docs]\n\n[docs]: https://example.com",
},
{
id: `${messageId}:block:1`,
blockGroupId: messageId,
blockIndex: 1,
text: "Closing paragraph.",
},
]);
const blocks = (items: StreamItem[]) =>
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
});
// Rows are addressed by id from outside presentation, so the same text has to land on
// the same ids whether the reader watched it arrive or opened the chat afterwards.
it("gives a streamed message the block ids its hydrated text would have had", () => {
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
const harness = streamHarness();
for (let end = 8; end < text.length; end += 8) {
harness.send(assistant(text.slice(end - 8, end)));
}
harness.send(assistant(text.slice(text.length - (text.length % 8 || 8))));
const streamed = rows(harness.send({ type: "turn_completed", provider: "claude" }));
const hydrated = rows(
createStreamPresentation()({
...presentationOptions,
tail: hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]),
head: [],
transform: undefined,
}),
);
expect(streamed.map((item) => item.id)).toEqual(hydrated.map((item) => item.id));
expect(streamed.map((item) => item.kind === "assistant_message" && item.text)).toEqual(
hydrated.map((item) => item.kind === "assistant_message" && item.text),
);
});
it("repoints a block at the canonical source that replaced the streamed one", () => {
const harness = streamHarness();
harness.send(assistant("Streamed answer"));
const streamed = rows(harness.render());
expect(streamed[0]).not.toHaveProperty("timelineCursor");
const source = harness.source();
const live = source.head[0];
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
const present = createStreamPresentation();
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
const repointed = rows(
present({ ...presentationOptions, tail: [], head: [canonical], transform: undefined }),
);
expect(repointed).toMatchObject([
{
id: streamed[0]!.id,
text: "Streamed answer",
timelineCursor: { epoch: "epoch-1", seq: 42 },
},
]);
});
it("keeps a single-paragraph message a one-block group", () => {
const source = hydrateStreamState([
{ event: assistant("Only one paragraph."), timestamp: new Date(1000) },
]);
const messageId = source[0]!.id;
const result = createStreamPresentation()({
...presentationOptions,
tail: source,
head: [],
transform: undefined,
});
expect(result.tail).toEqual(source);
expect(result.tail[0]).toBe(source[0]);
expect(result.tail).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "Only one paragraph.",
},
]);
expect(result.tail[0]!.id).not.toBe(messageId);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
@@ -441,7 +533,13 @@ describe("timeline presentation", () => {
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
},
];
expect(projectTimelineItems(items)).toEqual(items);
expect(projectTimelineItems(items)).toEqual(
items.map((item) =>
item.kind === "assistant_message"
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
: item,
),
);
expect(projectTimelineItems(items)[0]).toBe(items[0]);
});
+40 -14
View File
@@ -23,6 +23,31 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
: next;
}
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
* addresses a message — find, scroll-to-message, history reveal — asks for this.
*/
export function getStreamItemMessageId(item: StreamItem): string {
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
}
/**
* A block is reusable only when it still stands for the same source state. Text and
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
* through the row — a reused block carrying a pre-canonical cursor reports a stale
* reading position and weakens fork-boundary resolution.
*/
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
return (
block.id === id &&
block.turnId === source.turnId &&
block.timestamp.getTime() === source.timestamp.getTime() &&
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
block.timelineCursor?.seq === source.timelineCursor?.seq
);
}
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -54,6 +79,13 @@ export function createStreamPresentation() {
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
/**
* One display row per Markdown block. Each block renders on its own, so a construct
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
* link reference definitions with the block that uses them, but a reference pointing
* at a definition several blocks away renders as literal text. Streamed messages
* always behaved this way; history now matches them.
*/
function nativeBlocks(item: StreamItem): StreamItem[] {
if (item.kind === "user_message") return [presentUserMessage(item)];
if (item.kind !== "assistant_message") return [item];
@@ -71,11 +103,9 @@ export function createStreamPresentation() {
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
const textBlocks = splitMarkdownBlocks(growingText);
if (prefix.length + textBlocks.length < 2) {
blocksBySource.set(item, [item]);
return [item];
}
const parsed = splitMarkdownBlocks(growingText);
// Whitespace-only text has no block, and a message still owns exactly one row.
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
const blocks = [...prefix];
for (const [offset, text] of textBlocks.entries()) {
@@ -87,9 +117,7 @@ export function createStreamPresentation() {
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
// Completed display blocks keep their first cursor and object identity while
// the source message grows. The source itself always retains the latest cursor.
if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) {
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
blocks.push(existing);
continue;
}
@@ -108,13 +136,11 @@ export function createStreamPresentation() {
return (input: PresentationInput) => {
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
// One rendering path: history splits the same way the live head does, and
// `blocksBySource` keeps both the live block identities and the split cost
// from being paid again when only the tail's array identity changed.
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
(item) => {
// Preserve live block identities at completion; fetched native Markdown
// stays whole so links and other cross-block constructs keep their context.
if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item];
return [item.kind === "user_message" ? presentUserMessage(item) : item];
},
nativeBlocks,
);
historySource = input.tail;
historyTransform = input.transform;
@@ -5,7 +5,7 @@ interface PromptJumpScrollMetrics {
}
export interface PromptJumpSettleViewport {
findTargetTop(itemId: string): number | null;
findTargetTop(messageId: string): number | null;
getContainerTop(): number;
getScrollMetrics(): PromptJumpScrollMetrics;
setScrollTop(scrollTop: number): void;
@@ -13,7 +13,7 @@ export interface PromptJumpSettleViewport {
}
export interface PromptJumpSettleController {
start(itemId: string): void;
start(messageId: string): void;
cancel(): void;
isActive(): boolean;
}
@@ -32,7 +32,7 @@ export function createPromptJumpSettleController(input: {
requestFrame: (callback: () => void) => number;
cancelFrame: (frameId: number) => void;
}): PromptJumpSettleController {
let activeItemId: string | null = null;
let activeMessageId: string | null = null;
let frameId: number | null = null;
let sampledFrames = 0;
let stableFrames = 0;
@@ -41,7 +41,7 @@ export function createPromptJumpSettleController(input: {
function finish(): void {
if (frameId !== null) input.cancelFrame(frameId);
unsubscribeFromUserIntent?.();
activeItemId = null;
activeMessageId = null;
frameId = null;
sampledFrames = 0;
stableFrames = 0;
@@ -54,11 +54,11 @@ export function createPromptJumpSettleController(input: {
function tick(): void {
frameId = null;
const itemId = activeItemId;
if (itemId === null) return;
const messageId = activeMessageId;
if (messageId === null) return;
sampledFrames += 1;
const targetTop = input.viewport.findTargetTop(itemId);
const targetTop = input.viewport.findTargetTop(messageId);
if (targetTop !== null) {
const delta = distanceFromLandingPosition(targetTop, input.viewport.getContainerTop());
if (Math.abs(delta) <= POSITION_TOLERANCE_PX) {
@@ -72,7 +72,7 @@ export function createPromptJumpSettleController(input: {
const before = input.viewport.getScrollMetrics();
input.viewport.setScrollTop(before.scrollTop + delta);
const after = input.viewport.getScrollMetrics();
const adjustedTargetTop = input.viewport.findTargetTop(itemId);
const adjustedTargetTop = input.viewport.findTargetTop(messageId);
let remainingDelta = 0;
if (adjustedTargetTop !== null) {
remainingDelta = distanceFromLandingPosition(
@@ -97,15 +97,15 @@ export function createPromptJumpSettleController(input: {
}
return {
start(itemId) {
start(messageId) {
finish();
activeItemId = itemId;
activeMessageId = messageId;
unsubscribeFromUserIntent = input.viewport.subscribeToUserIntent(finish);
scheduleNextFrame();
},
cancel: finish,
isActive() {
return activeItemId !== null;
return activeMessageId !== null;
},
};
}
+42 -3
View File
@@ -7,7 +7,12 @@ import React, {
useRef,
useState,
} from "react";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import {
defaultRangeExtractor,
measureElement as measureVirtualElement,
useVirtualizer,
type Range as VirtualRange,
} from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -36,6 +41,8 @@ import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
import { getStreamItemMessageId } from "./presentation";
import { useScrollToMessage } from "./use-scroll-to-message.web";
interface CreateWebStreamStrategyInput {
@@ -369,6 +376,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
// Chat find counts and highlights occurrences from the DOM, so every block row of
// the message it selected has to be mounted even when the scroll window is nowhere
// near it. Without this a hit in a far paragraph is invisible to the count and Next
// walks off to the following message.
const chatFindMessageId = useChatFindSelectedMessageId();
const chatFindRowIndexes = useMemo(() => {
if (!chatFindMessageId) return null;
const indexes = segments.historyVirtualized.flatMap((item, index) =>
getStreamItemMessageId(item) === chatFindMessageId ? [index] : [],
);
return indexes.length > 0 ? indexes : null;
}, [chatFindMessageId, segments.historyVirtualized]);
const rangeExtractor = useCallback(
(range: VirtualRange) => {
const visible = defaultRangeExtractor(range);
if (!chatFindRowIndexes) return visible;
return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right);
},
[chatFindRowIndexes],
);
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
@@ -379,6 +406,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return row ? estimateStreamItemHeight(row) : 120;
},
measureElement: measureVirtualElement,
rangeExtractor,
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
@@ -1192,7 +1220,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
<div
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</div>
));
@@ -1200,7 +1233,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const liveHeadRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
<div
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderLiveHeadRow(item, index, segments.liveHead)}
</div>
));
@@ -1253,6 +1291,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={virtualRow.key}
data-index={virtualRow.index}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
+11 -4
View File
@@ -40,13 +40,20 @@ export interface StreamEdgeSlotProps {
ListFooterComponentStyle?: StyleProp<ViewStyle>;
}
/**
* A caller that landed on one occurrence inside the message owns where the viewport
* settles. It resolved the occurrence against a specific row already, so it takes no
* row here and returns null once that row is gone.
*/
export interface ScrollToMessageOccurrence {
signal: AbortSignal;
targetTop(): number | null;
}
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (
itemId: string,
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
) => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
export interface StreamSegmentRenderers {
@@ -1,13 +1,16 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId } from "./presentation";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
import type { ScrollToMessageOccurrence } from "./strategy";
interface UseScrollToMessageInput {
active: boolean;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowVirtualizer: Virtualizer<HTMLElement, Element>;
historyVirtualized: readonly { id: string }[];
historyVirtualized: readonly StreamItem[];
cancelPendingStickToBottom: () => void;
setFollowOutput: (value: boolean) => boolean;
onNearBottomChange: (value: boolean) => void;
@@ -32,24 +35,22 @@ export function useScrollToMessage({
setFollowOutput,
onNearBottomChange,
}: UseScrollToMessageInput) {
const occurrenceRef = useRef<
{ signal: AbortSignal; targetTop(row: HTMLElement): number | null } | undefined
>(undefined);
const occurrenceRef = useRef<ScrollToMessageOccurrence | undefined>(undefined);
const removeAbortListener = useRef<(() => void) | null>(null);
const settleController = useMemo(
() =>
createPromptJumpSettleController({
viewport: {
findTargetTop(itemId) {
const container = scrollContainerRef.current;
const target = container?.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
);
findTargetTop(messageId) {
const occurrence = occurrenceRef.current;
if (occurrence?.signal.aborted) return null;
if (!target) return null;
if (occurrence) return occurrence.targetTop(target);
return target.getBoundingClientRect().top;
if (occurrence) return occurrence.targetTop();
// Every row of an assistant message carries the message id; the first one
// is the message's top.
const target = scrollContainerRef.current?.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
return target ? target.getBoundingClientRect().top : null;
},
getContainerTop() {
return scrollContainerRef.current?.getBoundingClientRect().top ?? 0;
@@ -102,10 +103,7 @@ export function useScrollToMessage({
}, [active, settleController]);
const scrollToMessage = useCallback(
(
itemId: string,
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
) => {
(messageId: string, occurrence?: ScrollToMessageOccurrence) => {
occurrenceRef.current = occurrence;
removeAbortListener.current?.();
const cancel = () => settleController.cancel();
@@ -118,7 +116,7 @@ export function useScrollToMessage({
setFollowOutput(false);
const mounted = container.querySelector<HTMLElement>(
`[data-history-row-id="${CSS.escape(itemId)}"]`,
`[data-message-id="${CSS.escape(messageId)}"]`,
);
if (mounted) {
const delta =
@@ -126,15 +124,17 @@ export function useScrollToMessage({
container.getBoundingClientRect().top -
PROMPT_JUMP_TOP_INSET_PX;
container.scrollTop += delta;
settleController.start(itemId);
settleController.start(messageId);
onNearBottomChange(false);
return;
}
const index = historyVirtualized.findIndex((item) => item.id === itemId);
const index = historyVirtualized.findIndex(
(item) => getStreamItemMessageId(item) === messageId,
);
if (index >= 0) {
rowVirtualizer.scrollToIndex(index, { align: "start" });
settleController.start(itemId);
settleController.start(messageId);
onNearBottomChange(false);
}
},
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getStreamItemMessageId } from "./presentation";
// Callers reveal a message, not a row: an assistant message is several block rows and
// the window has to open at the first of them.
function findMessageRowIndex(items: StreamItem[], messageId: string): number {
return items.findIndex((item) => getStreamItemMessageId(item) === messageId);
}
function findEarlierHistoryWindowStart(input: {
items: StreamItem[];
start: number;
itemId: string | undefined;
}): number {
const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1;
const targetIndex = input.itemId ? findMessageRowIndex(input.items, input.itemId) : -1;
const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start;
if (!input.itemId) {
return findMountedWindowStart({
@@ -90,7 +97,7 @@ export function useStreamHistoryWindow(input: {
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1;
const targetIndex = itemId ? findMessageRowIndex(items, itemId) : -1;
if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) {
return false;
}
+12 -8
View File
@@ -62,7 +62,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -605,10 +605,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const handleTimelineHistoryLoadError = useCallback(() => {
toast?.error(t("agentStream.historyLoadFailed"));
}, [t, toast]);
const visibleHistoryItemIds = useMemo(
// Chat find and the chat outline address messages, and an assistant message is a
// group of block rows, so this is a set of message ids and never of row ids.
const visibleMessageIds = useMemo(
() =>
new Set(
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id),
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
getStreamItemMessageId,
),
),
[baseRenderModel.history, baseRenderModel.segments.liveHead],
);
@@ -621,8 +625,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleItemIds: visibleHistoryItemIds,
revealLoadedItem: revealLoadedHistory,
visibleMessageIds,
revealLoadedMessage: revealLoadedHistory,
});
useImperativeHandle(
@@ -718,7 +722,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
>
<ChatFindExpansion itemId={item.id}>
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
{(renderFullContent) => (
<AssistantMessage
renderFullContent={renderFullContent}
@@ -1098,8 +1102,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedItem={revealLoadedHistory}
visibleItemIds={visibleHistoryItemIds}
revealLoadedMessage={revealLoadedHistory}
visibleMessageIds={visibleMessageIds}
>
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
@@ -87,6 +87,31 @@ function selectNodeContents(element: Element): Selection {
return selection;
}
interface TranscriptMessage {
messageId: string;
blocks: string[];
}
/**
* The web transcript's shape: a scroll container holding one row per Markdown block,
* each row carrying its message id and wrapping its own assistant message element.
*/
function mountTranscript(messages: TranscriptMessage[]): HTMLElement {
const transcript = document.createElement("div");
transcript.setAttribute("data-testid", "agent-chat-scroll");
for (const { messageId, blocks } of messages) {
for (const [index, text] of blocks.entries()) {
const row = document.createElement("div");
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
row.setAttribute("data-message-id", messageId);
row.innerHTML = `<div data-testid="assistant-message"><div data-paseo-markdown-tag="p">${text}</div></div>`;
transcript.append(row);
}
}
document.body.append(transcript);
return transcript;
}
function copiedMarkdown(selection: Selection): string | null {
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
}
@@ -110,6 +135,36 @@ describe("assistant selection copy ranges", () => {
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
});
it("copies a selection that runs across the block rows of one message", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
]);
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
});
it("does not replace the browser clipboard for a selection reaching a second message", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
{ messageId: "message-2", blocks: ["Reply paragraph."] },
]);
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
expect(copiedMarkdown(selectRange(blocks[1]!, 0, blocks[2]!, 5))).toBeNull();
});
// A retained inactive panel renders the same agent, so the same message id can exist
// twice in the document. A group is only ever read out of one transcript, and rows
// that belong to no transcript are not a group at all.
it("does not assemble a message group from rows outside a transcript", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
]);
document.body.append(...transcript.children);
transcript.remove();
const blocks = document.querySelectorAll('[data-paseo-markdown-tag="p"]');
expect(copiedMarkdown(selectRange(blocks[0]!, 0, blocks[1]!, 17))).toBeNull();
});
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
const firstMessage = mountFixture();
const secondMessage = mountFixture();
@@ -17,6 +17,9 @@ import {
} from "./markup";
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
const MESSAGE_ROW_SELECTOR = "[data-message-id]";
const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
@@ -64,20 +67,22 @@ export function createAssistantSelectionClipboardContent(
}
const range = selection.getRangeAt(0);
const startMessage = closestAssistantMessage(range.startContainer);
const endMessage = closestAssistantMessage(range.endContainer);
if (!startMessage || startMessage !== endMessage) {
const parts = selectedMessageParts(range);
if (!parts) {
return null;
}
const partialCode = createPartialCodeContent(range, startMessage);
if (partialCode) {
return partialCode;
if (parts.length === 1) {
const partialCode = createPartialCodeContent(range, parts[0]!.message);
if (partialCode) {
return partialCode;
}
}
const container = document.createElement("div");
const selected = cloneMarkdownSelection(range, startMessage);
container.append(selected);
for (const part of parts) {
container.append(cloneMarkdownSelection(part.range, part.message));
}
restoreMarkdownElements(container);
const markdown = turndown.turndown(container.innerHTML).trim();
@@ -407,6 +412,70 @@ function closestAssistantMessage(node: Node): Element | null {
return element?.closest(ASSISTANT_MESSAGE_SELECTOR) ?? null;
}
interface SelectedMessagePart {
message: Element;
range: Range;
}
function messageIdOf(node: Element): string | undefined {
return node.closest<HTMLElement>(MESSAGE_ROW_SELECTOR)?.dataset.messageId;
}
/**
* A retained inactive panel keeps rendering its chat, so the same agent — and the same
* message id — can exist twice in the document. The selection's own transcript is the
* only one that can answer for it.
*/
function assistantMessagesOfGroup(node: Element, messageId: string): Element[] {
const transcript = node.closest(CHAT_SCROLL_SELECTOR);
if (!transcript) {
return [];
}
const rows = transcript.querySelectorAll<HTMLElement>(messageRowSelector(messageId));
return Array.from(rows).flatMap((row) => {
const message = row.querySelector(ASSISTANT_MESSAGE_SELECTOR);
return message ? [message] : [];
});
}
/**
* The selection cut into one range per assistant message element it covers.
*
* An assistant message renders one row per Markdown block, so a selection that runs
* past a paragraph ends in a different element of the same message. Each block is
* serialized against its own element, exactly as a selection inside one block is, and
* the results are concatenated — Turndown never sees the row wrappers between them.
* A selection reaching a second message, or anything that is not assistant Markdown,
* copies as plain text.
*/
function selectedMessageParts(range: Range): SelectedMessagePart[] | null {
const start = closestAssistantMessage(range.startContainer);
const end = closestAssistantMessage(range.endContainer);
if (!start || !end) {
return null;
}
if (start === end) {
return [{ message: start, range }];
}
const messageId = messageIdOf(start);
if (!messageId || messageId !== messageIdOf(end)) {
return null;
}
const messages = assistantMessagesOfGroup(start, messageId);
const first = messages.indexOf(start);
const last = messages.indexOf(end);
if (first < 0 || last < first) {
return null;
}
return messages.slice(first, last + 1).map((message) => {
const part = message.ownerDocument.createRange();
part.selectNodeContents(message);
if (message === start) part.setStart(range.startContainer, range.startOffset);
if (message === end) part.setEnd(range.endContainer, range.endOffset);
return { message, range: part };
});
}
function restoreMarkdownElements(container: HTMLElement): void {
for (const ignored of container.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
ignored.remove();
@@ -1788,7 +1788,7 @@ export const AgentControls = memo(function AgentControls({
onEditAgentProfiles={handleEditAgentProfiles}
onCreateAgentProfile={profileActions.create}
onEditAgentProfile={profileActions.edit}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption}
features={agent.features}
@@ -10,7 +10,7 @@ import { EditorView } from "@codemirror/view";
import { View, type View as ViewInstance } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { usePaneFocus } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
@@ -41,12 +41,7 @@ export function FileFind({
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
+3 -2
View File
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
revealFailure: "تعذر عرض هذا التطابق. أعد المحاولة.",
searching: "جارٍ البحث…",
loading: "جارٍ التحميل…",
failed: "فشل",
retry: "إعادة المحاولة",
chatPosition: "{{current}} من {{total}} في الرسالة",
title: "بحث",
placeholder: "بحث في اللوحة",
+3 -2
View File
@@ -1,11 +1,12 @@
export const en = {
paneFind: {
searchFailed: "Could not search this chat. Check the host connection and retry.",
connectionFailure: "Could not search this chat. Check the host connection and retry.",
historyChangedFailure: "The chat changed while searching. Search again.",
revealFailure: "Could not show this match. Retry.",
searching: "Searching…",
loading: "Loading…",
failed: "Failed",
retry: "Retry",
chatPosition: "{{current}} of {{total}} in message",
title: "Find",
placeholder: "Find in pane",
close: "Close Find",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
historyChangedFailure: "El chat cambió durante la búsqueda. Vuelve a buscar.",
revealFailure: "No se pudo mostrar esta coincidencia. Vuelve a intentarlo.",
searching: "Buscando…",
loading: "Cargando…",
failed: "Error",
retry: "Reintentar",
chatPosition: "{{current}} de {{total}} en el mensaje",
title: "Buscar",
placeholder: "Buscar en el panel",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
historyChangedFailure: "La conversation a changé pendant la recherche. Relancez la recherche.",
revealFailure: "Impossible d’afficher ce résultat. Réessayez.",
searching: "Recherche…",
loading: "Chargement…",
failed: "Échec",
retry: "Réessayer",
chatPosition: "{{current}} sur {{total}} dans le message",
title: "Rechercher",
placeholder: "Rechercher dans le panneau",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
historyChangedFailure: "検索中にチャットが変更されました。もう一度検索してください。",
revealFailure: "この一致を表示できませんでした。再試行してください。",
searching: "検索中…",
loading: "読み込み中…",
failed: "失敗",
retry: "再試行",
chatPosition: "メッセージ内 {{current}} / {{total}}",
title: "検索",
placeholder: "ペイン内を検索",
+3 -2
View File
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
revealFailure: "이 일치 항목을 표시할 수 없습니다. 다시 시도하세요.",
searching: "검색 중…",
loading: "불러오는 중…",
failed: "실패",
retry: "다시 시도",
chatPosition: "메시지 내 {{current}} / {{total}}",
title: "찾기",
placeholder: "패널에서 찾기",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
historyChangedFailure: "A conversa mudou durante a busca. Busque novamente.",
revealFailure: "Não foi possível mostrar esta correspondência. Tente novamente.",
searching: "Buscando…",
loading: "Carregando…",
failed: "Falhou",
retry: "Tentar novamente",
chatPosition: "{{current}} de {{total}} na mensagem",
title: "Buscar",
placeholder: "Buscar no painel",
+3 -2
View File
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
historyChangedFailure: "Чат изменился во время поиска. Выполните поиск снова.",
revealFailure: "Не удалось показать это совпадение. Повторите попытку.",
searching: "Поиск…",
loading: "Загрузка…",
failed: "Ошибка",
retry: "Повторить",
chatPosition: "{{current}} из {{total}} в сообщении",
title: "Найти",
placeholder: "Найти в панели",
+3 -2
View File
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
searchFailed: "无法搜索此聊天。请检查主机连接后重试。",
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
revealFailure: "无法显示此匹配项。请重试。",
searching: "搜索中…",
loading: "加载中…",
failed: "失败",
retry: "重试",
chatPosition: "消息内第 {{current}} 项,共 {{total}} 项",
title: "查找",
placeholder: "在窗格中查找",
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { isFindShortcut } from "./find-shortcut";
const MAC = { isMac: true };
const NON_MAC = { isMac: false };
function keydown(
overrides: Partial<Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">>,
) {
return {
key: "f",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
...overrides,
};
}
describe("on macOS", () => {
it("opens Find with Command+F", () => {
expect(isFindShortcut(keydown({ metaKey: true }), MAC)).toBe(true);
});
it("leaves Control+F to text editing", () => {
expect(isFindShortcut(keydown({ ctrlKey: true }), MAC)).toBe(false);
});
it("ignores Control+Command+F", () => {
expect(isFindShortcut(keydown({ metaKey: true, ctrlKey: true }), MAC)).toBe(false);
});
it("ignores Shift and Alt variants", () => {
expect(isFindShortcut(keydown({ metaKey: true, shiftKey: true }), MAC)).toBe(false);
expect(isFindShortcut(keydown({ metaKey: true, altKey: true }), MAC)).toBe(false);
});
});
describe("off macOS", () => {
it("opens Find with Control+F", () => {
expect(isFindShortcut(keydown({ ctrlKey: true }), NON_MAC)).toBe(true);
});
it("ignores Meta+F", () => {
expect(isFindShortcut(keydown({ metaKey: true }), NON_MAC)).toBe(false);
});
it("ignores Control+Meta+F", () => {
expect(isFindShortcut(keydown({ ctrlKey: true, metaKey: true }), NON_MAC)).toBe(false);
});
it("ignores Shift and Alt variants", () => {
expect(isFindShortcut(keydown({ ctrlKey: true, shiftKey: true }), NON_MAC)).toBe(false);
expect(isFindShortcut(keydown({ ctrlKey: true, altKey: true }), NON_MAC)).toBe(false);
});
});
it("matches an uppercase F from a shifted keyboard layout", () => {
expect(isFindShortcut(keydown({ key: "F", metaKey: true }), MAC)).toBe(true);
expect(isFindShortcut(keydown({ key: "F", ctrlKey: true }), NON_MAC)).toBe(true);
});
it("ignores other keys", () => {
expect(isFindShortcut(keydown({ key: "g", metaKey: true }), MAC)).toBe(false);
expect(isFindShortcut(keydown({ key: "g", ctrlKey: true }), NON_MAC)).toBe(false);
});
@@ -0,0 +1,24 @@
export interface FindShortcutPlatform {
isMac: boolean;
}
/**
* The one Find keystroke policy: Command+F on macOS, Control+F everywhere else.
*
* Control+F is a macOS text-editing binding that moves the caret forward, so a
* surface that also claims it there eats a keystroke the text editor owns.
*
* The platform is a parameter rather than a lookup because this module is
* imported by the terminal emulator runtime, which is bundled on its own into
* the terminal WebView and cannot load React Native. App surfaces pass
* `findShortcutPlatform()` from `@/pane-find`.
*/
export function isFindShortcut(
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
platform: FindShortcutPlatform,
): boolean {
const primaryModifier = platform.isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
}
+17 -6
View File
@@ -33,6 +33,15 @@ import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut";
export { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut";
/** The platform every Find surface judges the shortcut against. */
export function findShortcutPlatform(): FindShortcutPlatform {
return { isMac: getShortcutOs() === "mac" };
}
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
@@ -166,12 +175,14 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
keyCode?: number;
};
if (isImeComposingKeyboardEvent(key)) return;
if (
(key.metaKey || key.ctrlKey) &&
!key.altKey &&
!key.shiftKey &&
key.key.toLowerCase() === "f"
) {
const shortcut = {
key: key.key,
metaKey: key.metaKey === true,
ctrlKey: key.ctrlKey === true,
shiftKey: key.shiftKey === true,
altKey: key.altKey === true,
};
if (isFindShortcut(shortcut, findShortcutPlatform())) {
// RN Web inputs stop keydown before the pane's document listener.
event.preventDefault();
event.stopPropagation();
@@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => {
).toEqual(directoryPlacement);
store.clearSession(serverId);
});
it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => {
const serverId = "agent-replica-timeline-membership";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
// A conversation opened from history arrives over its timeline, without the
// project placement the directory carries, and is a member from then on.
replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only"));
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
replica.commitChanges([], [], []);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
});
@@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { AgentStoreProjection } from "./internal/agent-store";
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
return agent.projectPlacement
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
: null;
// A member hydrated from its timeline or restored from the replica cache carries
// no placement. Every other path in this flow derives one from the working
// directory, so this one does too; returning nothing here would drop the member
// from the merged directory and delete the conversation on the next catch-up.
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry {
return {
agent: projectAgentSnapshot(agent),
project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }),
};
}
export interface AgentLifecycleToken {
@@ -164,8 +170,7 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const merged = new Map<string, FetchAgentsEntry>();
for (const agent of previous.values()) {
const entry = projectAgentDirectoryEntry(agent);
if (entry) merged.set(agent.id, entry);
merged.set(agent.id, projectAgentDirectoryEntry(agent));
}
for (const entry of entries) merged.set(entry.agent.id, entry);
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
@@ -640,6 +640,63 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("reconciles agent changes on top of the accepted cached baseline", async () => {
const serverId = "cached-agent-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "cached-agent");
const releaseNetwork = client.holdAgentFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map([[cachedAgent.id, cachedAgent]]),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshAgents();
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
releaseNetwork({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastAgentOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
@@ -134,6 +134,7 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
readCursor: () => undefined,
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
onCatchUpEnded: () => undefined,
reportError: () => undefined,
schedule: () => () => undefined,
});
+2 -3
View File
@@ -10,12 +10,11 @@ import {
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { isWeb } from "@/constants/platform";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract";
import { isFindShortcut } from "../runtime/terminal-find-shortcut";
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
export interface TerminalPaneFindHandle {
@@ -65,7 +64,7 @@ export const TerminalFind = forwardRef<
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (isFindShortcut(event) && terminal.current?.find) {
if (isFindShortcut(event, findShortcutPlatform()) && terminal.current?.find) {
event.preventDefault();
show();
}
@@ -28,7 +28,8 @@ import {
type TerminalLocalFileLinkSource,
type TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
import { isMac, isFindShortcut } from "./terminal-find-shortcut";
import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut";
import { isMacUserAgent } from "@/utils/mac-user-agent";
import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font";
export type TerminalOutputData = Uint8Array;
@@ -178,7 +179,7 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
}
export class TerminalEmulatorRuntime {
constructor(private readonly options: { isMac: boolean } = { isMac }) {}
constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {}
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
@@ -1,14 +0,0 @@
export const isMac =
typeof navigator !== "undefined" &&
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
export function isFindShortcut(
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
platform = { isMac },
): boolean {
const primaryModifier = platform.isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
}
File diff suppressed because one or more lines are too long
+114
View File
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import {
createTimelineReplica,
@@ -443,6 +444,119 @@ describe("viewed timeline persistence", () => {
});
});
describe("create handoff lifetime", () => {
const DRAFT_ID = "draft-1";
afterEach(() => useCreateFlowStore.getState().clearAll());
function sentCreateHandoff(): void {
useCreateFlowStore.getState().setPending({
draftId: DRAFT_ID,
serverId: SERVER_ID,
agentId: AGENT_ID,
clientMessageId: "client-message-1",
text: "Create this chat.",
timestamp: 0,
});
useCreateFlowStore.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
}
function createHandoff(): PendingCreateAttempt | undefined {
return useCreateFlowStore.getState().pendingByDraftId[DRAFT_ID];
}
function syncedTailPage(): Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] {
return {
requestId: "tail-1",
agentId: AGENT_ID,
agent: null,
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
entries: [],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
};
}
interface HeldCatchUpOwner {
owner: ViewedTimelineOwner;
release: () => void;
}
async function ownerWithHeldCatchUp(): Promise<HeldCatchUpOwner> {
let releaseTailFetch!: () => void;
let tailFetchStarted = false;
const tailFetched = new Promise<void>((resolve) => {
releaseTailFetch = resolve;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: { readTimeline: async () => undefined, commitTimeline: () => undefined },
prepareAgent: async () => undefined,
});
const owner = createViewedTimelineOwner({
serverId: SERVER_ID,
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
fetchPage: async () => {
tailFetchStarted = true;
await tailFetched;
return { hasNewer: false, endCursor: null };
},
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
owner.setConnected(true);
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect.poll(() => tailFetchStarted).toBe(true);
return { owner, release: releaseTailFetch };
}
it("keeps the handoff through the first synchronized page and releases it when the chat is current", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
sentCreateHandoff();
const { owner, release } = await ownerWithHeldCatchUp();
owner.applyTimelineResponse(syncedTailPage());
expect(
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
).toMatchObject({ status: "synced" });
expect(createHandoff()).toMatchObject({ agentId: AGENT_ID, lifecycle: "sent" });
release();
await expect.poll(() => createHandoff()).toBeUndefined();
owner.dispose();
});
it("releases the handoff when the chat's tab closes before catch-up completes", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
sentCreateHandoff();
const { owner, release } = await ownerWithHeldCatchUp();
owner.replaceVisibleAgentIds("test", []);
owner.replaceOpenTabAgentIds([]);
expect(createHandoff()).toBeUndefined();
release();
owner.dispose();
});
});
function createSqliteCache() {
const database = new DatabaseSync(":memory:");
let beforeRead = async () => {};
@@ -6,6 +6,7 @@ import {
consumeForcedTimelineTailReplacement,
createViewedTimelineSync,
type TimelineResponsePayload,
type ViewedTimelineStatus,
type ViewedTimelineSyncPorts,
} from "./viewed-timeline-sync";
@@ -31,6 +32,11 @@ interface MembershipRequest {
fail(message: string): void;
}
interface TimelineCurrentReport {
agentId: string;
status: ViewedTimelineStatus;
}
interface TimelineFetch {
agentId: string;
request: ProjectedTimelineForwardFetchPlan;
@@ -126,9 +132,16 @@ class TimelineWorld {
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
readonly cacheRequests: string[] = [];
readonly forcedTimelineTailReplacements = new Set<string>();
readonly reportedCurrent: TimelineCurrentReport[] = [];
cacheGate: Deferred<void> | null = null;
readonly sync = createViewedTimelineSync({
replaceDemandedAgentIds: () => undefined,
onCatchUpEnded: (agentId) => {
this.reportedCurrent.push({
agentId,
status: this.sync.getAgentTimelineStatus(agentId),
});
},
prepare: async (agentId) => {
this.cacheRequests.push(agentId);
this.cacheRequestWaiters.shift()?.(agentId);
@@ -436,6 +449,60 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("reports a chat current only once its parked follow-up page completes", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const tail = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
tail.respond({ hasNewer: false });
const recovery = await world.nextFetch("agent-a");
expect(world.reportedCurrent).toEqual([]);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
recovery.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
world.sync.dispose();
});
test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const probe = await world.nextFetch("agent-a");
probe.respond({ hasNewer: true, seq: 82 });
const fallback = await world.nextFetch("agent-a");
expect(world.reportedCurrent).toEqual([]);
fallback.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
world.sync.dispose();
});
test("stops owing a chat a catch-up when its tab closes mid-fetch", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("pane", ["agent-a"]);
(await world.nextMembership()).succeed();
await world.nextFetch("agent-a");
expect(world.reportedCurrent).toEqual([]);
world.sync.replaceVisibleAgentIds("pane", []);
world.sync.replaceOpenTabAgentIds([]);
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "pending" }]);
world.sync.dispose();
});
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -247,9 +247,6 @@ function finalizeProcessedTimeline(input: {
clearAgentInitializingFlag(input.serverId, input.agentId);
}
if (input.synchronized) {
useCreateFlowStore
.getState()
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
const session = useSessionStore.getState().sessions[input.serverId];
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
@@ -317,6 +314,11 @@ export interface ViewedTimelineSyncPorts {
request: ProjectedTimelineForwardFetchPlan,
): Promise<TimelinePageResult>;
fetchLatestTail(agentId: string): Promise<TimelinePageResult>;
/**
* The sync no longer owes this chat a catch-up: it reached current, or it left the
* demanded set. Disconnect and backgrounding keep the obligation and do not report here.
*/
onCatchUpEnded(agentId: string): void;
reportError(error: unknown): void;
schedule(task: () => void, delayMs: number): () => void;
}
@@ -346,7 +348,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
export type ViewedTimelineOwnerPorts = Omit<
ViewedTimelineSyncPorts,
"prepare" | "replaceDemandedAgentIds"
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
>;
export interface ViewedTimelineOwner extends ViewedTimelineSync {
@@ -376,6 +378,8 @@ export function createViewedTimelineOwner(input: {
prepare: (agentId) => input.replica.prepare(agentId),
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
onCatchUpEnded: (agentId) =>
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
});
const streamQueue = createSessionAgentStreamReducerQueue({
serverId: input.serverId,
@@ -530,6 +534,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const wasPending = visibilityCatchUpPending.delete(agentId);
const hadError = visibilityCatchUpErrors.delete(agentId);
const wasRetrying = manualRetries.delete(agentId);
ports.onCatchUpEnded(agentId);
if (wasPending || hadError || wasRetrying) notifyListeners();
};
@@ -785,12 +790,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
return;
}
const released: string[] = [];
for (const agentId of desired) {
if (!nextDesired.includes(agentId)) {
cancelCatchUp(agentId);
visibilityCatchUpPending.delete(agentId);
visibilityCatchUpErrors.delete(agentId);
manualRetries.delete(agentId);
released.push(agentId);
}
}
for (const agentId of nextDesired) {
@@ -806,6 +813,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
desired = nextDesired;
ports.replaceDemandedAgentIds(desired);
membershipGeneration += 1;
for (const agentId of released) ports.onCatchUpEnded(agentId);
notifyListeners();
void reconcileMembership();
};
@@ -75,6 +75,49 @@ describe("splitMarkdownBlocks", () => {
).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]);
});
it("keeps a link reference definition with the paragraph that uses it", () => {
expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([
"See the [docs][d].\n\n[d]: https://example.com",
]);
});
it("folds a leading definition-only block into the block below it", () => {
expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([
'[d]: https://example.com "Docs"\n\nSee the [docs][d].',
]);
});
it("folds several definition lines and several definition blocks into one block", () => {
expect(
splitMarkdownBlocks(
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'\n\nAfter",
),
).toEqual([
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'",
"After",
]);
});
it("recognizes every destination the renderer accepts, including escaped spaces", () => {
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([
"See [docs].\n\n[docs]: docs\\ folder/readme",
]);
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: <docs folder/readme> 'Title'")).toEqual([
"See [docs].\n\n[docs]: <docs folder/readme> 'Title'",
]);
});
it("leaves a definition-only message as its own block", () => {
expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]);
});
it("does not fold a paragraph that merely starts with a bracketed link", () => {
expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([
"Intro",
"[Link](https://example.com) and more prose",
]);
});
it("treats triple newlines as a split point and filters empty blocks", () => {
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
"First paragraph",
+14
View File
@@ -0,0 +1,14 @@
/**
* What the browser says about the platform. `getShortcutOs()` is the app's
* answer for keyboard policy and consults native and Electron first; this is the
* browser half of it, kept free of React Native imports so the terminal WebView
* bundle can use it too.
*/
export function isMacUserAgent(): boolean {
if (typeof navigator === "undefined") return false;
const userAgent = navigator.userAgent ?? "";
const platform = (navigator as Navigator & { platform?: string }).platform ?? "";
return (
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(userAgent) || /Mac|iPhone|iPad|iPod/i.test(platform)
);
}
+2 -6
View File
@@ -2,16 +2,12 @@ import { Platform } from "react-native";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
import { isNative } from "@/constants/platform";
import { isMacUserAgent } from "@/utils/mac-user-agent";
export function getShortcutOs(): ShortcutOs {
if (isNative) {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsElectronRuntimeMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as Navigator & { platform?: string }).platform ?? "";
const isApple =
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac";
return isMacUserAgent() ? "mac" : "non-mac";
}
@@ -4,6 +4,35 @@ import MarkdownIt from "markdown-it";
const markdownBlockParser = new MarkdownIt();
markdownBlockParser.core.ruler.disable("inline");
// The renderer decides what counts as a definition, so ask the same parser: a block
// that produces no tokens but registers references is nothing but definitions.
function isLinkReferenceDefinitionBlock(block: string): boolean {
const env: { references?: Record<string, unknown> } = {};
const tokens = markdownBlockParser.parse(block, env);
return tokens.length === 0 && Object.keys(env.references ?? {}).length > 0;
}
/**
* Definitions render nothing and resolve nothing on their own, so a block made only of
* them would paint an empty row and break every reference that pointed at it. Fold it
* into the block it belongs to: the one above, or the one below when it leads.
*/
function foldLinkReferenceDefinitions(blocks: string[]): string[] {
const folded: string[] = [];
let leading: string[] = [];
for (const block of blocks) {
if (isLinkReferenceDefinitionBlock(block)) {
if (folded.length > 0) folded[folded.length - 1] += `\n\n${block}`;
else leading.push(block);
continue;
}
folded.push([...leading, block].join("\n\n"));
leading = [];
}
if (leading.length > 0) folded.push(leading.join("\n\n"));
return folded;
}
export function splitMarkdownBlocks(text: string): string[] {
if (text.length === 0) {
return [];
@@ -43,7 +72,7 @@ export function splitMarkdownBlocks(text: string): string[] {
blocks.push(currentLines.join("\n"));
}
return blocks.filter((block) => block.length > 0);
return foldLinkReferenceDefinitions(blocks.filter((block) => block.length > 0));
}
function getStructuralBlankLines(text: string, lines: string[]): Set<number> {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/server": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+6 -1
View File
@@ -52,10 +52,15 @@ interface ProviderDiagnostic {
}
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-opus-5-5",
model: "Opus 5.5",
descriptionFragment: "Latest release",
},
{
id: "claude-opus-5",
model: "Opus 5",
descriptionFragment: "Latest release",
descriptionFragment: "Previous release",
},
{
id: "claude-fable-5-1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/relay": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -65,8 +65,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -76,8 +76,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+7 -1
View File
@@ -4608,7 +4608,13 @@ export const AgentTimelineSearchResponseMessageSchema = z.object({
agentId: z.string(),
epoch: z.string(),
locations: z.array(
z.object({ seq: z.number().int().nonnegative(), role: z.enum(["user", "assistant"]) }),
z.object({
seq: z.number().int().nonnegative(),
role: z.enum(["user", "assistant"]),
// Estimated occurrences in the message; the client verifies against rendered text.
// COMPAT(timelineSearchCount): added in v0.9.0, remove optional after 2027-09-22.
count: z.number().int().positive().optional(),
}),
),
nextCursor: z.number().int().nonnegative().nullable(),
error: z.string().nullable(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -106,11 +106,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.0-beta.2",
"@getpaseo/highlight": "0.9.0-beta.2",
"@getpaseo/plugin": "0.9.0-beta.2",
"@getpaseo/protocol": "0.9.0-beta.2",
"@getpaseo/relay": "0.9.0-beta.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -4767,6 +4767,93 @@ test("later explicit config mutations win over events emitted by earlier mutatio
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
});
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
class ClampingSession extends TestAgentSession {
async setThinkingOption(): Promise<void> {}
override async getRuntimeInfo() {
return {
...(await super.getRuntimeInfo()),
thinkingOptionId: "high",
};
}
}
class ClampingClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ClampingSession(config);
}
}
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new ClampingClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000135",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
thinkingOptionId: "low",
},
undefined,
{ workspaceId: undefined },
);
await manager.setAgentThinkingOption(snapshot.id, "medium");
await manager.flush();
const agent = manager.getAgent(snapshot.id);
expect(agent?.config.thinkingOptionId).toBe("high");
expect(agent?.runtimeInfo?.thinkingOptionId).toBe("high");
const persisted = await storage.get(snapshot.id);
expect(persisted?.config?.thinkingOptionId).toBe("high");
});
test("setAgentThinkingOption surfaces a failed state read and keeps the previous level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-read-failure-"));
class UnreadableStateSession extends TestAgentSession {
async setThinkingOption(): Promise<void> {}
override async getRuntimeInfo(): Promise<never> {
throw new Error("state unavailable");
}
}
class UnreadableStateClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new UnreadableStateSession(config);
}
}
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new UnreadableStateClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000136",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
thinkingOptionId: "low",
},
undefined,
{ workspaceId: undefined },
);
await expect(manager.setAgentThinkingOption(snapshot.id, "medium")).rejects.toThrow(
"state unavailable",
);
await manager.flush();
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low");
const persisted = await storage.get(snapshot.id);
expect(persisted?.config?.thinkingOptionId).toBe("low");
});
test("session config drift events update state through the stream channel", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
let capturedSession: TestAgentSession | null = null;
@@ -1934,11 +1934,17 @@ export class AgentManager {
}
await this.drainSessionEvents(agentId);
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
let effectiveThinkingOptionId = normalizedThinkingOptionId;
const runtimeInfo = await agent.session.getRuntimeInfo();
if (runtimeInfo.thinkingOptionId !== undefined) {
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
}
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: normalizedThinkingOptionId,
thinkingOptionId: effectiveThinkingOptionId,
};
}
this.touchUpdatedAt(agent);
@@ -19,7 +19,10 @@ it("returns source locations for formatted assistant text and literal user text"
]),
query: "hello world",
});
expect(result).toEqual({ locations: [{ seq: 2, role: "assistant" }], nextCursor: null });
expect(result).toEqual({
locations: [{ seq: 2, role: "assistant", count: 2 }],
nextCursor: null,
});
});
it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => {
@@ -34,8 +37,8 @@ it("finds code and literal punctuation without searching tools, reasoning, or ta
query: "a.b",
});
expect(result.locations).toEqual([
{ seq: 1, role: "assistant" },
{ seq: 5, role: "user" },
{ seq: 1, role: "assistant", count: 1 },
{ seq: 5, role: "user", count: 1 },
]);
});
@@ -49,8 +52,8 @@ it("bounds results and resumes without losing locations", async () => {
const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! });
expect(second).toEqual({
locations: [
{ seq: 201, role: "user" },
{ seq: 202, role: "user" },
{ seq: 201, role: "user", count: 1 },
{ seq: 202, role: "user", count: 1 },
],
nextCursor: null,
});
@@ -59,9 +62,27 @@ it("bounds results and resumes without losing locations", async () => {
it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => {
const source = rows([{ type: "assistant_message", text: "hello &amp; **world**\nnext line" }]);
expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([
{ seq: 1, role: "assistant" },
{ seq: 1, role: "assistant", count: 1 },
]);
expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([
{ seq: 1, role: "assistant" },
{ seq: 1, role: "assistant", count: 1 },
]);
});
it("counts occurrences per rendered block rather than across block boundaries", async () => {
const source = rows([
{ type: "assistant_message", text: "target one\n\ntarget two\n\n- target three\n- end target" },
{ type: "user_message", text: "TARGET target" },
]);
expect((await searchTimeline({ rows: source, query: "target" })).locations).toEqual([
{ seq: 1, role: "assistant", count: 4 },
{ seq: 2, role: "user", count: 2 },
]);
expect((await searchTimeline({ rows: source, query: "end target" })).locations).toEqual([
{ seq: 1, role: "assistant", count: 1 },
]);
expect((await searchTimeline({ rows: source, query: "two target" })).locations).toEqual([]);
// Rendered text never runs from the end of one block into the start of the next, so
// neither does a match, however contiguous the Markdown source looks.
expect((await searchTimeline({ rows: source, query: "one target" })).locations).toEqual([]);
});
@@ -8,6 +8,7 @@ const PAGE_SIZE = 200;
interface SearchLocation {
seq: number;
role: "user" | "assistant";
count: number;
}
interface SearchInput {
rows: readonly AgentTimelineRow[];
@@ -15,8 +16,13 @@ interface SearchInput {
cursor?: number;
}
// Discovery is deliberately approximate. Only the client can verify displayed text.
function searchableMarkdown(text: string): string {
// Discovery is deliberately approximate. Only the client can verify displayed text, so
// `count` estimates rendered occurrences and the client replaces it with the rendered
// count once the message is on screen. Blocks are counted separately, and the Markdown
// source is never searched whole, because a match the client cannot highlight — across
// a block boundary, or on syntax the reader never sees — still inflates the total until
// navigation reaches that message and drops it.
function searchableBlocks(text: string): string[] {
return markdown
.parse(text, {})
.map((token) => {
@@ -32,28 +38,44 @@ function searchableMarkdown(text: string): string {
if (token.type === "fence" || token.type === "code_block") return token.content;
return "";
})
.filter(Boolean)
.join("\n");
.filter(Boolean);
}
function searchPattern(query: string): RegExp {
const source = query
.trim()
.split(/\s+/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+");
return new RegExp(source, "giu");
}
function countMatches(text: string, pattern: RegExp): number {
let count = 0;
for (const _ of text.matchAll(pattern)) count += 1;
return count;
}
export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) {
const locations: SearchLocation[] = [];
if (!query.trim()) return { locations, nextCursor: null };
const needle = normalizeSearchText(query);
const pattern = searchPattern(query);
let lastYield = performance.now();
for (const entry of projectTimelineRows({ rows, mode: "projected" })) {
if (entry.seqEnd <= cursor) continue;
const item = entry.item;
if (item.type !== "user_message" && item.type !== "assistant_message") continue;
const raw = item.text.replace(/\r/g, "");
const text = item.type === "assistant_message" ? searchableMarkdown(raw) : raw;
if (normalizeSearchText(raw).includes(needle) || normalizeSearchText(text).includes(needle)) {
const blocks = item.type === "assistant_message" ? searchableBlocks(raw) : [raw];
const count = blocks.reduce((sum, block) => sum + countMatches(block, pattern), 0);
if (count) {
if (locations.length === PAGE_SIZE) {
return { locations, nextCursor: locations[PAGE_SIZE - 1]!.seq };
}
locations.push({
seq: entry.seqEnd,
role: item.type === "user_message" ? "user" : "assistant",
count,
});
}
if (performance.now() - lastYield >= 8) {
@@ -63,7 +85,3 @@ export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) {
}
return { locations, nextCursor: null };
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}
@@ -476,7 +476,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
force: false,
});
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
expect(models.map((model) => model.id)).toContain("claude-fable-5");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
@@ -0,0 +1,79 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
import {
canRunRealProvider,
createRealProviderClient,
} from "../../../daemon-e2e/real-provider-test-config.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
// Adaptive thinking is the model's choice, so the prompt has to be hard enough
// that it always reasons. A trivial sum does not think at all.
const THINKING_PROMPT =
"You have 12 identical-looking balls; exactly one has a different weight (you do not " +
"know if heavier or lighter). Using a balance scale only 3 times, give the complete " +
"decision procedure that always identifies the odd ball AND whether it is heavy or " +
"light. Work out every branch.";
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, THINKING_PROMPT)) {
events.push(event);
if (isTerminalEvent(event)) {
return events;
}
}
return events;
}
function reasoningTexts(events: AgentStreamEvent[]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "reasoning" ? [event.item.text] : [],
);
}
describe("Claude thinking display (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await canRunRealProvider("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("emits reasoning timeline items when thinking is enabled", async () => {
const client = createRealProviderClient("claude", pino({ level: "silent" }));
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "bypassPermissions",
model: "claude-sonnet-5",
thinkingOptionId: "high",
});
try {
const events = await collectUntilTerminal(session);
const failure = events.find((event) => event.type === "turn_failed");
expect(failure, JSON.stringify(failure)).toBeUndefined();
const reasoning = reasoningTexts(events);
expect(reasoning.length).toBeGreaterThan(0);
expect(reasoning.join("").trim().length).toBeGreaterThan(0);
} finally {
await session.close().catch(() => undefined);
}
}, 180_000);
});
@@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry {
minimumClaudeCodeVersion?: string;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
defaultThinkingOptionId?: ClaudeEffortLevel;
supportsThinkingDisabled?: boolean;
supportsFastMode?: boolean;
}
@@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-opus-5-5",
label: "Opus 5.5",
description: "Opus 5.5 · Latest release",
defaultPriority: 3,
minimumClaudeCodeVersion: "2.1.280",
defaultThinkingOptionId: "medium",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-opus-5",
label: "Opus 5",
description: "Opus 5 · Latest release",
description: "Opus 5 · Previous release",
defaultPriority: 2,
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 1_000_000,
@@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [
},
] as const satisfies readonly ClaudeModelManifestEntry[];
function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel {
return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID;
}
function buildThinkingOptions(
effortLevels: readonly ClaudeEffortLevel[] | undefined,
supportsThinkingDisabled: boolean,
defaultThinkingOptionId: ClaudeEffortLevel,
): AgentSelectOption[] | undefined {
if (!effortLevels) {
return undefined;
@@ -170,7 +187,7 @@ function buildThinkingOptions(
...effortLevels.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id],
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
...(id === defaultThinkingOptionId ? { isDefault: true } : {}),
})),
];
@@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
const thinkingOptions = buildThinkingOptions(
model.effortLevels,
model.supportsThinkingDisabled === true,
getDefaultThinkingOptionId(model),
);
const definition: AgentModelDefinition = {
provider: "claude",
@@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
}
if (thinkingOptions) {
definition.thinkingOptions = thinkingOptions;
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model);
}
definitions.push(definition);
if (!("aliases" in model) || !model.aliases) {
@@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel(
supported:
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
fallbackThinkingOptionId:
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined,
};
}
@@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
/**
* Normalize a Claude Code runtime/config model string to a known manifest ID.
* Runtime metadata may include provider prefixes such as Bedrock model IDs; feature
* gates should use normalizeClaudeManifestModelId instead.
* gates should use normalizeClaudeManifestModelId instead. The prefixed matches are
* unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the
* "claude-opus-5" entry.
*/
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const normalizedManifestModelId = normalizeClaudeManifestModelId(value);
@@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
return null;
}
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
const runtimeMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (singleSegmentMatch) {
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
if (runtimeMatch) {
const normalizedModelId = normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
trimmed.toLowerCase().includes("[1m]"),
);
if (normalizedModelId) {
@@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
}
const runtimeMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
if (!singleSegmentMatch) {
return null;
}
return normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
return normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
trimmed.toLowerCase().includes("[1m]"),
);
}
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.219"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
@@ -50,6 +50,7 @@ describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5-5",
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
@@ -72,7 +73,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
const defaults = models.filter((m) => m.isDefault);
expect(defaults).toHaveLength(1);
expect(defaults[0].id).toBe("claude-opus-5");
expect(defaults[0].id).toBe("claude-opus-5-5");
});
it("defines context window sizes in the catalog", () => {
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-opus-5-5", 1_000_000],
["claude-opus-5", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
@@ -109,6 +111,11 @@ describe("getClaudeModels", () => {
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
});
it("derives thinking options from model effort capabilities", () => {
@@ -169,6 +176,8 @@ describe("getClaudeModels", () => {
});
it.each([
["claude-opus-5-5", false, "medium"],
["claude-opus-5-5-20260401", false, "medium"],
["claude-opus-5", true, "high"],
["claude-opus-5-20260724", true, "high"],
["claude-sonnet-5", true, "high"],
@@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => {
"claude-opus-4-8",
);
});
it("does not collapse a prefixed minor release onto the major it extends", () => {
expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5");
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe(
"claude-opus-5-5",
);
expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe(
"claude-fable-5-1",
);
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe(
"claude-opus-5",
);
});
});
describe("parseClaudeCodeVersion", () => {
@@ -438,7 +460,7 @@ describe("findClaudeModel", () => {
describe("Claude Opus 5 catalog", () => {
it("offers a single Opus 5 entry with a 1M context window", () => {
const opus5Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5"))
.filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus5Models).toEqual([
@@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => {
});
});
describe("Claude Opus 5.5 catalog", () => {
it("offers one Opus 5.5 entry with a 1M context window", () => {
const opus55Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5-5"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus55Models).toEqual([
{ id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 },
]);
});
it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => {
const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5");
expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(opus55?.defaultThinkingOptionId).toBe("medium");
expect(
opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
).toEqual(["medium"]);
});
it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => {
expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5");
expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5");
expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5");
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
@@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
});
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
@@ -5991,6 +5991,138 @@ describe("Codex app-server provider", () => {
});
describe("Codex importable sessions", () => {
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
// the caller asks for, and hands back a cursor for the remaining threads.
function cappedThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const params = (input ?? {}) as {
limit?: number;
cursor?: string;
cwd?: string;
sortKey?: string;
};
const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt";
const matching = threads
.filter((thread) => !params.cwd || thread.cwd === params.cwd)
.sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey]));
const offset = Number(params.cursor ?? 0);
const page = matching.slice(
offset,
offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP),
);
const nextOffset = offset + page.length;
return {
data: page,
nextCursor: nextOffset < matching.length ? String(nextOffset) : null,
};
};
}
function threadsUsedInReverseCreationOrder(count: number) {
// The most recently used thread is also the oldest one created, so a scan
// that follows Codex's creation-time page order misses it.
return Array.from({ length: count }, (_, index) => ({
id: `thread-${index}`,
cwd: "/workspace/project-a",
preview: `Session ${index}`,
createdAt: index,
updatedAt: count - index,
}));
}
test("fills the requested window across the pages Codex caps at 100 rows", async () => {
const threads = threadsUsedInReverseCreationOrder(250);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler([
...threads,
{ id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 },
]),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({
limit: 240,
cwd: "/workspace/project-a",
});
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 240).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps the scanned window on the most recently used threads", async () => {
const threads = threadsUsedInReverseCreationOrder(550);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler(threads),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 500).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps a thread once when it moves onto a later page mid-scan", async () => {
// A thread used while the scan is paging sorts to the front and comes back
// on the next page.
const appServer = createFakeCodexAppServer({
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
return cursor
? {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 },
{ id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 },
],
nextCursor: null,
}
: {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 },
{ id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 },
],
nextCursor: "page-2",
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual([
"thread-1",
"thread-2",
"thread-3",
]);
appServer.assertNoErrors();
});
test("stops scanning when Codex pages without handing back a new thread", async () => {
const appServer = createFakeCodexAppServer({
// A cursor cycle: the two pages after the first serve threads the scan
// already holds and point at each other, so it can never fill its window.
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
const thread = { id: "thread-1", cwd: "/workspace/project-a" };
if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" };
if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" };
return { data: [thread], nextCursor: "page-a" };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]);
appServer.assertNoErrors();
});
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [
{
@@ -6071,7 +6203,10 @@ describe("Codex importable sessions", () => {
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
},
},
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
{
method: "thread/list",
params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" },
},
]);
});
});
@@ -914,6 +914,60 @@ const CodexModelListResponseSchema = z.object({
.optional(),
});
/**
* Read the newest `window.limit` threads Codex knows about.
*
* Codex caps every `thread/list` response at 100 rows whatever limit it is
* given (codex-cli 0.153 and 0.155), so one request only ever sees the newest
* page and hands back a cursor for the rest. Follow that cursor until the
* caller's window is full or Codex runs out of threads.
*/
async function readCodexThreadWindow(
client: Pick<CodexAppServerClientLike, "request">,
logger: Logger,
window: { limit: number; cwd?: string },
): Promise<Array<Record<string, unknown>>> {
// A thread updated while the scan is paging moves under `updated_at` order
// and can come back on a later page, so identify each one and keep it once.
// A row Codex sends without an id stands for itself.
const threads = new Map<unknown, Record<string, unknown>>();
let cursor: string | undefined;
while (threads.size < window.limit) {
const response = toObjectRecord(
await client.request("thread/list", {
limit: window.limit - threads.size,
// Rank the window by last use. Codex pages by creation time by default,
// which drops an old conversation that is still being worked in.
// Older Codex builds ignore the unknown key and keep that order.
sortKey: "updated_at",
...(window.cwd ? { cwd: window.cwd } : {}),
...(cursor ? { cursor } : {}),
}),
);
const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const sizeBeforePage = threads.size;
for (const thread of page) {
const identity = typeof thread.id === "string" ? thread.id : thread;
if (!threads.has(identity)) threads.set(identity, thread);
}
const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined;
if (!nextCursor) break;
if (threads.size === sizeBeforePage) {
// Every page that continues the scan brings a thread the scan has not
// seen, so the loop is bounded by the window. A page that brings none
// would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for
// ever behind a caller that has already timed out.
logger.warn(
{ cursor: nextCursor },
"codex thread/list returned no new threads mid-scan, stopping the session scan",
);
break;
}
cursor = nextCursor;
}
return [...threads.values()];
}
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
@@ -7143,13 +7197,10 @@ export class CodexAppServerAgentClient implements AgentClient {
// filtering since most threads will be from other cwds, then keep the
// local realpath-aware filter for symlink-equivalent workspace paths.
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
const response = toObjectRecord(
await client.request("thread/list", {
limit: listLimit,
...(options?.cwd ? { cwd: options.cwd } : {}),
}),
);
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit,
cwd: options?.cwd,
});
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
@@ -145,6 +145,18 @@ const MODELS: AgentModelDefinition[] = [
intervalMs: 5,
},
},
{
provider: MOCK_LOAD_TEST_PROVIDER_ID,
id: "max-only-thinking-stream",
label: "Max-only thinking stream",
description: "Fast realistic stream with one supported thinking level.",
thinkingOptions: [{ id: "max", label: "Max", isDefault: true }],
defaultThinkingOptionId: "max",
metadata: {
durationMs: 10_000,
intervalMs: 5,
},
},
{
provider: MOCK_LOAD_TEST_PROVIDER_ID,
id: "e2e-fast-stream",
@@ -24,11 +24,35 @@ import {
transformPiModels,
} from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js";
import type { PiModel, PiThinkingLevel } from "./rpc-types.js";
import type { PiUsagePollScheduler } from "./usage-poller.js";
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
const RESTRICTED_THINKING_MODEL: PiModel = {
provider: "kimi-coding",
id: "kimi-k3",
name: "Kimi K3",
reasoning: true,
thinkingLevelMap: {
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
},
};
interface PiThinkingCatalogCase {
name: string;
model: PiModel;
optionIds: PiThinkingLevel[] | undefined;
defaultThinkingOptionId: PiThinkingLevel | undefined;
}
test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => {
expect(PiProviderParamsSchema.parse({}).rpcTimeoutMs).toBe(60_000);
expect(PiProviderParamsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000);
@@ -179,6 +203,51 @@ test("keeps normal Pi agent sessions persisted", async () => {
await session.close();
});
test("lets Pi choose the thinking level when none is requested", async () => {
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.state = {
...session.state,
model: RESTRICTED_THINKING_MODEL,
thinkingLevel: "max",
};
});
const session = await createClient(pi).createSession(
createConfig({ model: "kimi-coding/kimi-k3" }),
);
onTestFinished(() => session.close());
expect(pi.recordedLaunches[0]?.thinkingOptionId).toBeUndefined();
expect(pi.recordedLaunches[0]?.argv).not.toContain("--thinking");
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("max");
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "max" });
});
test.each([
{ requested: "medium", effective: "high" },
{ requested: "xhigh", effective: "max" },
] as const)(
"adopts Pi's $effective level when creating with $requested",
async ({ requested, effective }) => {
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.state = {
...session.state,
model: RESTRICTED_THINKING_MODEL,
thinkingLevel: effective,
};
});
const session = await createClient(pi).createSession(
createConfig({ model: "kimi-coding/kimi-k3", thinkingOptionId: requested }),
);
onTestFinished(() => session.close());
expect(pi.recordedLaunches[0]?.thinkingOptionId).toBe(requested);
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(effective);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: effective });
},
);
class SessionEvents {
private readonly events: AgentStreamEvent[] = [];
private readonly waiters: Array<{
@@ -1370,6 +1439,32 @@ describe("PiRpcAgentSession", () => {
]);
});
test("adopts Pi's clamped thinking level when resuming a session", async () => {
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.state = {
...session.state,
model: RESTRICTED_THINKING_MODEL,
thinkingLevel: "high",
};
});
const session = await createClient(pi).resumeSession({
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: {
cwd: "/workspace/project",
model: "kimi-coding/kimi-k3",
thinkingOptionId: "medium",
},
});
onTestFinished(() => session.close());
expect(pi.recordedLaunches[0]?.thinkingOptionId).toBe("medium");
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("high");
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "high" });
});
test("reports the persisted Pi entry attached to the submitted message", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -1436,8 +1531,6 @@ describe("PiRpcAgentSession", () => {
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--extension",
actualLaunch.extensionPaths[0],
]);
@@ -1507,6 +1600,91 @@ describe("PiRpcAgentSession", () => {
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
});
test.each([
{ requested: "medium", effective: "high", rpcRequest: "medium" },
{ requested: "xhigh", effective: "max", rpcRequest: "xhigh" },
{ requested: null, effective: "high", rpcRequest: "medium" },
] as const)(
"stores Pi's $effective thinking level after requesting $requested",
async ({ requested, effective, rpcRequest }) => {
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.state = {
...session.state,
model: RESTRICTED_THINKING_MODEL,
thinkingLevel: "low",
};
});
const { session } = await createSession(pi);
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.state = { ...fakeSession.state, thinkingLevel: effective };
await session.setThinkingOption(requested);
expect(fakeSession.setThinkingLevelRequests).toEqual([rpcRequest]);
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(effective);
await expect(session.getRuntimeInfo()).resolves.toEqual({
provider: "pi",
sessionId: "pi-session-1",
model: "kimi-coding/kimi-k3",
thinkingOptionId: effective,
modeId: null,
});
},
);
test("refreshes Pi's thinking level after changing models", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.setModelResult = RESTRICTED_THINKING_MODEL;
fakeSession.state = { ...fakeSession.state, thinkingLevel: "high" };
await session.setModel("kimi-coding/kimi-k3");
expect(fakeSession.setModelRequests).toEqual([{ provider: "kimi-coding", modelId: "kimi-k3" }]);
expect(session.describePersistence()?.metadata).toEqual({
cwd: "/tmp/paseo-pi-rpc-test",
model: "kimi-coding/kimi-k3",
thinkingOptionId: "high",
});
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
model: "kimi-coding/kimi-k3",
thinkingOptionId: "high",
});
});
test("reports updated Pi thinking state instead of a cached selection", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.state = { ...fakeSession.state, thinkingLevel: "off" };
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "off" });
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off");
});
test("surfaces state refresh failures after a Pi thinking update", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
pi.latestSession().getStateError = new Error("Pi state unavailable");
await expect(session.setThinkingOption("high")).rejects.toThrow("Pi state unavailable");
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("medium");
});
test("surfaces state refresh failures after a Pi model update", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.setModelResult = RESTRICTED_THINKING_MODEL;
fakeSession.getStateError = new Error("Pi state unavailable");
await expect(session.setModel("kimi-coding/kimi-k3")).rejects.toThrow("Pi state unavailable");
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("medium");
});
test("materializes image prompts as text hints for text-only Pi models", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
@@ -2253,6 +2431,9 @@ describe("PiRpcAgentClient", () => {
"utf8",
);
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.state.thinkingLevel = "high";
});
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
@@ -2324,6 +2505,113 @@ describe("PiRpcAgentClient", () => {
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
});
test("honors per-model Pi thinking maps and clamps the catalog default upward", async () => {
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.models = [RESTRICTED_THINKING_MODEL];
});
const client = createClient(pi);
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: "/workspace/project",
force: false,
});
expect(catalog.models).toEqual([
{
provider: "pi",
id: "kimi-coding/kimi-k3",
label: "Kimi K3",
description: "kimi-coding/kimi-k3",
metadata: { provider: "kimi-coding", modelId: "kimi-k3" },
thinkingOptions: [
{ id: "low", label: "Low", description: "Faster reasoning" },
{ id: "high", label: "High", description: "Deeper reasoning", isDefault: true },
{ id: "max", label: "Max", description: "Extreme reasoning" },
],
defaultThinkingOptionId: "high",
},
]);
});
test.each<PiThinkingCatalogCase>([
{
name: "no thinking map",
model: { ...RESTRICTED_THINKING_MODEL, thinkingLevelMap: undefined },
optionIds: ["off", "minimal", "low", "medium", "high"],
defaultThinkingOptionId: "medium",
},
{
name: "a partial thinking map",
model: {
...RESTRICTED_THINKING_MODEL,
thinkingLevelMap: { minimal: null, medium: null, xhigh: "extra-high", max: null },
},
optionIds: ["off", "low", "high", "xhigh"],
defaultThinkingOptionId: "high",
},
{
name: "explicitly mapped extended levels",
model: {
...RESTRICTED_THINKING_MODEL,
thinkingLevelMap: { xhigh: "extra-high", max: "maximum" },
},
optionIds: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
defaultThinkingOptionId: "medium",
},
{
name: "only lower thinking levels",
model: {
...RESTRICTED_THINKING_MODEL,
thinkingLevelMap: { medium: null, high: null },
},
optionIds: ["off", "minimal", "low"],
defaultThinkingOptionId: "low",
},
{
name: "a non-reasoning model",
model: { ...RESTRICTED_THINKING_MODEL, reasoning: false },
optionIds: undefined,
defaultThinkingOptionId: undefined,
},
{
name: "no supported thinking levels",
model: {
...RESTRICTED_THINKING_MODEL,
thinkingLevelMap: {
...RESTRICTED_THINKING_MODEL.thinkingLevelMap,
low: null,
high: null,
max: null,
},
},
optionIds: [],
defaultThinkingOptionId: "off",
},
])(
"resolves Pi thinking options for $name",
async ({ model, optionIds, defaultThinkingOptionId }) => {
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.models = [model];
});
const catalog = await createClient(pi).fetchCatalog({
scope: "workspace",
cwd: "/workspace/project",
force: false,
});
expect(catalog.models).toHaveLength(1);
const thinkingOptions = catalog.models[0]?.thinkingOptions;
expect(thinkingOptions?.map((option) => option.id)).toEqual(optionIds);
expect(catalog.models[0]?.defaultThinkingOptionId).toBe(defaultThinkingOptionId);
expect(
thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
).toEqual(optionIds?.filter((id) => id === defaultThinkingOptionId));
},
);
test("lists no draft features without starting a Pi session", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -2622,8 +2910,6 @@ describe("PiRpcAgentClient", () => {
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--mcp-config",
actualLaunch.mcpConfigPath,
"--extension",
@@ -2704,8 +2990,6 @@ describe("PiRpcAgentClient", () => {
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--extension",
actualLaunch.extensionPaths[0],
]);
@@ -24,6 +24,7 @@ import {
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSelectOption,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
@@ -171,12 +172,11 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{
id: PiThinkingLevel;
label: string;
description: string;
isDefault?: boolean;
}> = [
{ id: "off", label: "Off", description: "No extra reasoning" },
{ id: "minimal", label: "Minimal", description: "Light reasoning" },
{ id: "low", label: "Low", description: "Faster reasoning" },
{ id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true },
{ id: "medium", label: "Medium", description: "Balanced reasoning" },
{ id: "high", label: "High", description: "Deeper reasoning" },
{ id: "xhigh", label: "XHigh", description: "Very deep reasoning" },
{ id: "max", label: "Max", description: "Extreme reasoning" },
@@ -373,21 +373,6 @@ function parseAutoCompactMode(value: string | undefined): AutoCompactMode {
return "unknown";
}
function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) {
const mappedOption = {
id: option.id,
label: option.label,
description: option.description,
};
if (option.isDefault) {
return {
...mappedOption,
isDefault: true,
};
}
return mappedOption;
}
function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
return model?.input?.includes("image") === true;
}
@@ -791,14 +776,6 @@ function isPiRequestAbortError(error: unknown): boolean {
return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error));
}
function resolveThinkingOptionId(
cachedThinkingOptionId: string | null,
sessionThinkingLevel: PiThinkingLevel,
): PiThinkingLevel | null {
const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel;
return normalizePiThinkingOption(currentThinking);
}
function modelToId(model: PiModel | null | undefined): string | null {
return model?.provider && model.id ? `${model.provider}/${model.id}` : null;
}
@@ -1195,7 +1172,50 @@ function buildExtensionUiResponse(
return { value: answer };
}
function resolvePiThinkingConfig(
model: PiModel,
): Pick<AgentModelDefinition, "thinkingOptions" | "defaultThinkingOptionId"> {
if (!model.reasoning) {
return { thinkingOptions: undefined, defaultThinkingOptionId: undefined };
}
const supportedOptions = PI_THINKING_OPTIONS.filter((option) => {
const mapped = model.thinkingLevelMap?.[option.id];
if (mapped === null) {
return false;
}
if (option.id === "xhigh" || option.id === "max") {
return mapped !== undefined;
}
return true;
});
const defaultIndex = PI_THINKING_OPTIONS.findIndex(
(option) => option.id === DEFAULT_PI_THINKING_LEVEL,
);
// Pi clamps upward first, then falls back to the highest remaining lower level.
const higherDefault = supportedOptions.find(
(option) => PI_THINKING_OPTIONS.indexOf(option) >= defaultIndex,
);
const defaultOption = higherDefault ?? supportedOptions.at(-1);
const defaultThinkingOptionId = defaultOption?.id ?? "off";
return {
thinkingOptions: supportedOptions.map((option) => {
const mappedOption: AgentSelectOption = {
id: option.id,
label: option.label,
description: option.description,
};
if (option.id === defaultThinkingOptionId) {
mappedOption.isDefault = true;
}
return mappedOption;
}),
defaultThinkingOptionId,
};
}
function mapPiModel(model: PiModel, provider: AgentProvider): AgentModelDefinition {
const { thinkingOptions, defaultThinkingOptionId } = resolvePiThinkingConfig(model);
return {
provider,
id: `${model.provider}/${model.id}`,
@@ -1205,8 +1225,8 @@ function mapPiModel(model: PiModel, provider: AgentProvider): AgentModelDefiniti
provider: model.provider,
modelId: model.id,
},
thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined,
defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined,
thinkingOptions,
defaultThinkingOptionId,
};
}
@@ -1244,7 +1264,6 @@ export class PiRpcAgentSession implements AgentSession {
private activePromptRequestId: string | null = null;
private readonly pendingPromptResults = new Map<string, boolean>();
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
private lastKnownThinkingOptionId: string | null;
currentLeafOverrideId: string | null | undefined;
private readonly capturedUserEntries: PiCapturedEntry[] = [];
private readonly capturedUserEntriesById = new Map<string, PiCapturedEntry>();
@@ -1266,14 +1285,11 @@ export class PiRpcAgentSession implements AgentSession {
this.runtimeSession = options.runtimeSession;
this.config = options.config;
this.state = options.initialState;
this.config.thinkingOptionId = this.state.thinkingLevel;
this.capabilities = options.capabilities;
this.provider = PI_PROVIDER;
this.currentModeId = options.currentModeId ?? null;
this.cleanup = options.cleanup;
this.lastKnownThinkingOptionId =
normalizePiThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
null;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
this.usagePoller = new PiUsagePoller({
@@ -1469,10 +1485,7 @@ export class PiRpcAgentSession implements AgentSession {
provider: this.provider,
sessionId: this.state.sessionId,
model: modelToId(this.state.model),
thinkingOptionId: resolveThinkingOptionId(
this.lastKnownThinkingOptionId,
this.state.thinkingLevel,
),
thinkingOptionId: this.state.thinkingLevel,
modeId: this.currentModeId,
};
}
@@ -1689,22 +1702,14 @@ export class PiRpcAgentSession implements AgentSession {
}
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
this.state = {
...this.state,
model,
};
await this.refreshState();
this.config.model = `${model.provider}/${model.id}`;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL;
await this.runtimeSession.setThinkingLevel(thinkingLevel);
this.lastKnownThinkingOptionId = thinkingLevel;
this.config.thinkingOptionId = thinkingLevel;
this.state = {
...this.state,
thinkingLevel,
};
await this.refreshState();
}
private emit(event: AgentStreamEvent): void {
@@ -2496,6 +2501,7 @@ export class PiRpcAgentSession implements AgentSession {
private async refreshState(): Promise<void> {
this.state = await this.runtimeSession.getState();
this.config.thinkingOptionId = this.state.thinkingLevel;
}
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
@@ -2542,8 +2548,7 @@ export class PiRpcAgentClient implements AgentClient {
runtimeSession = await this.runtime.startSession({
cwd: config.cwd,
model: config.model,
thinkingOptionId:
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined,
noSession: config.internal === true,
env: launchContext?.env,
mcpConfigPath: mcpConfig?.path,
@@ -74,6 +74,7 @@ export interface PiModel {
id: string;
name?: string;
reasoning?: boolean;
thinkingLevelMap?: Record<string, string | null>;
contextWindow?: number;
maxTokens?: number;
api?: string;
@@ -265,6 +265,7 @@ export class FakePiSession implements PiRuntimeSession {
if (!this.setModelResult) {
throw new Error("FakePi setModel requires setModelResult to be scripted");
}
this.state = { ...this.state, model: this.setModelResult };
return this.setModelResult;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.9.0-beta.2",
"version": "0.9.1",
"private": true,
"type": "module",
"scripts": {