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
Mohamed Boudra e9d32a17d6 chore(release): cut 0.9.0-beta.2 2026-09-18 11:41:13 +02:00
Mohamed Boudra 0fd1db5748 docs(changelog): add 0.9.0-beta.2 entry
Covers v0.9.0-beta.1..9696f4226. Only #5040 is user-facing; the other
commits in range are CI and Nix hash changes.
2026-09-18 11:36:22 +02:00
Mohamed Boudra 9696f42264 Subscribe only the chats this session opened (#5040)
* fix(app): subscribe only the chats this session opened

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

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

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

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

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

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

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

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

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

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

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

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

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

Set max-old-space-size per runner: 8192 on the 14 GB Intel runner, 4096 on
the 7 GB arm64 runner.
2026-09-17 21:58:02 +02:00
paseo-ai[bot] 2f3272490e fix: update lockfile signatures and Nix hash [skip ci] 2026-09-17 17:49:56 +00:00
114 changed files with 3384 additions and 573 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
format:
name: format
+9 -27
View File
@@ -102,8 +102,10 @@ jobs:
include:
- runner: macos-14
electron_arch: arm64
node_heap_mb: 4096
- runner: macos-15-intel
electron_arch: x64
node_heap_mb: 8192
permissions:
contents: write
packages: read
@@ -161,6 +163,10 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
# the last module in the web bundle. Node's default heap is sized from
# runner RAM, which is not enough on the Intel runner.
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
@@ -185,16 +191,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -294,16 +292,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -400,16 +390,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
+3
View File
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+15 -1
View File
@@ -1,6 +1,18 @@
# Changelog
## 0.9.0-beta.1 - 2026-09-17
## 0.9.1 - 2026-09-22
### Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0 - 2026-09-22
### Added
@@ -72,6 +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))
+7
View File
@@ -164,6 +164,13 @@ A finished workspace can be marked unread after it has been reviewed. The daemon
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
+17 -3
View File
@@ -29,9 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native
blocks and parses only the growing last block during append. Splitting in the reducer discards
paragraph separators and exposes fragments to plugin callbacks, which need the whole source text.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
+20 -8
View File
@@ -86,6 +86,13 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
first page leaves a chat this client just created looking hydrated but still catching up, which
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
one; disconnect and backgrounding keep it.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
@@ -166,14 +173,19 @@ replica cache.
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive every open workspace chat plus any visible agent pane. Workspace layout
owns open-chat lifetime, independently of mounted or retained React views. Switching workspaces,
evicting a retained view, and app backgrounding preserve that demand; closing the chat releases it.
Reconnect restores the open set and gives visible chats the first catch-up attempt. Hidden chats
follow when those attempts settle, including failures, so a failed visible chat does not starve
background recovery. Split panes catch up together. Hidden chats update the replica; on web their
retained presentation stays suspended until revealed, on native it keeps rendering. Revealing a
chat reads the current store and preserves its local UI state. There is no recent-agent limit.
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
enters that set the first time the user opens it and leaves when its tab closes or the session
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
subscribing from layout spawns a provider session per historical tab at every launch and stamps
every one of those workspaces as just used (see
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
does not starve background recovery. Split panes catch up together. Hidden chats update the
replica; on web their retained presentation stays suspended until revealed, on native it keeps
rendering. Revealing a chat reads the current store and preserves its local UI state.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-6+B64UqX8XtVdWFNtg884SIla6FXBOTtNSO0OMFoQfY=
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.0-beta.1",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.0-beta.1",
"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.1",
"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.1",
"version": "0.9.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/server": "0.9.0-beta.1",
"@getpaseo/client": "0.9.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.1",
"version": "0.9.1",
"dependencies": {
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/relay": "0.9.0-beta.1",
"@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.1",
"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.1",
"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.1",
"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.1",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/client": "0.9.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.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@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.1",
"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.1",
"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.1",
"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.1",
"@getpaseo/highlight": "0.9.0-beta.1",
"@getpaseo/plugin": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/relay": "0.9.0-beta.1",
"@getpaseo/client": "0.9.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.1",
"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.1",
"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);
});
});
@@ -5,6 +5,7 @@ import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-cl
import { getServerId } from "../support/helpers/server-id";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
@@ -53,9 +54,58 @@ async function seedViewedTimelineScenario(
};
}
interface RestoredLayoutChat {
workspaceId: string;
agentId: string;
title: string;
}
interface RestoredLayoutScenario {
chats: RestoredLayoutChat[];
cleanup(): Promise<void>;
}
/**
* Two workspaces with two chats each. Opening all four leaves the persisted workspace layout
* holding a tab per chat, which is the state a relaunch restores into.
*/
async function seedRestoredLayoutScenario(): Promise<RestoredLayoutScenario> {
const workspaces = await Promise.all([
seedWorkspace({ repoPrefix: "restored-layout-first-" }),
seedWorkspace({ repoPrefix: "restored-layout-second-" }),
]);
try {
const chats = await Promise.all(
workspaces.flatMap((workspace, workspaceIndex) =>
[1, 2].map(async (chatIndex): Promise<RestoredLayoutChat> => {
const title = `Restored chat ${workspaceIndex + 1}-${chatIndex}`;
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
});
return { workspaceId: workspace.workspaceId, agentId: agent.id, title };
}),
),
);
return {
chats,
cleanup: async () => {
for (const workspace of workspaces) await workspace.cleanup();
},
};
} catch (error) {
for (const workspace of workspaces) await workspace.cleanup().catch(() => undefined);
throw error;
}
}
async function openAgent(
page: Page,
scenario: ViewedTimelineScenario,
scenario: { workspaceId: string },
agentId: string,
options: { recordRenders?: boolean } = {},
) {
@@ -205,6 +255,43 @@ async function expectCurrentChatWithoutCatchUp(page: Page, message: string) {
}
test.describe("Viewed agent timelines", () => {
test("a reloaded layout subscribes only the chat it restores into", async ({ page }) => {
test.setTimeout(120_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedRestoredLayoutScenario();
try {
// One document load, then sidebar and tab clicks — the way a user reaches these chats.
// A `page.goto` per chat would restart the app and empty the session's set each time.
const [first, second, third, fourth] = scenario.chats;
await openAgent(page, first!, first!.agentId);
await selectAgent(page, second!.title);
await selectWorkspaceInSidebar(page, third!.workspaceId);
await selectAgent(page, third!.title);
await selectAgent(page, fourth!.title);
await subscriptions.waitForSubscribedAgents(scenario.chats.map((chat) => chat.agentId));
// Relaunch. Layout comes back from disk carrying a tab for every chat above; only the
// one on screen may be subscribed, because subscribing resumes the agent on the daemon.
const restored = scenario.chats.at(-1)!;
const sibling = scenario.chats.at(-2)!;
subscriptions.reset();
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: restored.title, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: sibling.title, exact: true })).toBeVisible();
await subscriptions.waitForSubscribedAgents([restored.agentId]);
// Opening the restored sibling is what adds it, and it adds only itself.
await selectAgent(page, sibling.title);
await subscriptions.waitForSubscribedAgents([restored.agentId, sibling.agentId]);
} finally {
await scenario.cleanup();
}
});
test("open chats stay current after switching beyond five agents", async ({ page }) => {
await withViewedTimelineScenario(async (scenario) => {
await openSevenChats(page, scenario);
@@ -18,6 +18,7 @@ export interface MockAgentOptions {
port?: number;
initialPrompt?: string;
model?: string;
thinkingOptionId?: string;
modeId?: string;
featureValues?: Record<string, unknown>;
}
@@ -43,6 +44,7 @@ export async function seedMockAgentWorkspace(
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "e2e-fast-stream",
thinkingOptionId: options.thinkingOptionId,
initialPrompt: options.initialPrompt,
featureValues: options.featureValues,
});
@@ -40,6 +40,13 @@ export function observeTimelineSubscriptions(page: Page) {
});
return {
/**
* Forget what the daemon acknowledged so far. Use this before a reload, so a wait that
* follows can only be satisfied by a subscription the reloaded app actually sent.
*/
reset(): void {
acknowledgedAgentIds = null;
},
async waitForSubscribedAgents(
agentIds: string[],
options: TimelineSubscriptionWaitOptions = {},
@@ -111,6 +111,50 @@ export async function expectReconnectingToastGone(
});
}
declare global {
interface Window {
__shownPanelToasts?: string[];
}
}
export interface PanelToastLog {
/** Test ids of every toast that entered the DOM since recording started, in order. */
shown(): Promise<string[]>;
expectNeverShown(testId: string): Promise<void>;
}
/**
* Record every toast that enters the DOM from the next navigation onward. A toast can appear
* and disappear within a few frames, so a settled `toHaveCount(0)` cannot prove one never showed.
*/
export async function recordPanelToasts(page: Page): Promise<PanelToastLog> {
await page.addInitScript(() => {
const shown: string[] = [];
window.__shownPanelToasts = shown;
const record = (node: Element) => {
if (node.getAttribute("role") !== "alert") return;
shown.push(node.getAttribute("data-testid") ?? "");
};
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
record(node);
for (const descendant of node.querySelectorAll('[role="alert"]')) record(descendant);
}
}
}).observe(document, { childList: true, subtree: true });
});
const shown = () => page.evaluate(() => window.__shownPanelToasts ?? []);
return {
shown,
async expectNeverShown(testId: string): Promise<void> {
expect(await shown()).not.toContain(testId);
},
};
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.0-beta.1",
"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}
@@ -498,7 +498,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setViewedTimelineSync(serverId, sync);
sync.setActive(getIsAppVisible(appStateRef.current));
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) =>
sync.replaceOpenAgentIds(agentIds),
sync.replaceOpenTabAgentIds(agentIds),
);
return () => {
@@ -10,7 +10,7 @@ import { EditorView } from "@codemirror/view";
import { View, type View as ViewInstance } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
import { usePaneFocus } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
@@ -41,12 +41,7 @@ export function FileFind({
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
if (isFindShortcut(event, findShortcutPlatform())) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
+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);
@@ -16,6 +16,7 @@ vi.mock("@react-native-async-storage/async-storage", () => {
});
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
import { defaultChangesState, type ChangesState } from "@/panels/changes/state";
import { defaultFileState, type FileState } from "@/panels/file/state";
@@ -107,6 +108,59 @@ it("observes open chats across unmounted workspaces until their tabs close", ()
expect(received).toEqual([[], ["agent-a"], ["agent-a", "agent-b"], ["agent-b"]]);
});
it("feeds restored layout to timeline sync as a release signal, not a subscription source", async () => {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
store.setState({ layoutByWorkspace: {} });
// Launch: layout rehydrates tabs the user opened in earlier sessions.
const restored = store.getState().openTab({
workspaceKey: "server-1:workspace-a",
target: { kind: "agent", agentId: "agent-a" },
intent: "reveal",
});
store.getState().openTab({
workspaceKey: "server-1:workspace-b",
target: { kind: "agent", agentId: "agent-b" },
intent: "background",
});
const observed: string[][] = [];
const sync = createViewedTimelineSync({
replaceDemandedAgentIds: () => undefined,
prepare: async () => undefined,
observe: (agentIds) => {
observed.push(agentIds);
return { ready: Promise.resolve(), release: async () => undefined };
},
readCursor: () => undefined,
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
onCatchUpEnded: () => undefined,
reportError: () => undefined,
schedule: () => () => undefined,
});
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
// agent behind a restored tab on the daemon.
const stop = observeOpenWorkspaceAgentIds(
"server-1",
(agentIds) => sync.replaceOpenTabAgentIds(agentIds),
store,
);
sync.setConnected(true);
expect(observed).toEqual([]);
sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
await vi.waitFor(() => expect(observed).toEqual([["agent-a"]]));
if (!restored) throw new Error("Expected an open agent tab");
store.getState().closeTab("server-1:workspace-a", restored);
sync.replaceVisibleAgentIds("workspace", []);
await vi.waitFor(() => expect(observed.at(-1)).toEqual([]));
stop();
sync.dispose();
});
function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
@@ -1794,7 +1794,12 @@ export function createWorkspaceLayoutStore(
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
/** Observe open chats independently of which workspace views are mounted. */
/**
* The agent tabs that exist right now, across every workspace of this host, independently
* of which workspace views are mounted. Timeline sync uses this to release a chat when its
* tab closes. It is not a subscription source: this layout is restored from disk at launch,
* so subscribing to it would resume every agent the user has ever opened.
*/
export function observeOpenWorkspaceAgentIds(
serverId: string,
listener: (agentIds: string[]) => void,
+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;
@@ -40,7 +46,7 @@ interface TimelineFetch {
test("split panes catch up together before hidden open chats start fetching", async () => {
const world = new TimelineWorld();
world.sync.replaceOpenAgentIds(["a-hidden", "y-visible", "z-visible"]);
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("panes", ["y-visible", "z-visible"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed();
@@ -57,7 +63,7 @@ test("split panes catch up together before hidden open chats start fetching", as
test("backgrounding releases hidden catch-ups waiting on a visible chat", async () => {
const world = new TimelineWorld();
world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]);
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed();
@@ -71,7 +77,7 @@ test("backgrounding releases hidden catch-ups waiting on a visible chat", async
test("visible catch-up settles before hidden open chats start fetching", async () => {
const world = new TimelineWorld();
world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]);
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed();
@@ -86,7 +92,7 @@ test("visible catch-up settles before hidden open chats start fetching", async (
test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => {
const world = new TimelineWorld();
world.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]);
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed();
@@ -104,7 +110,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its retr
test("switching chats promotes the newly visible catch-up while another visible request is pending", async () => {
const world = new TimelineWorld();
world.sync.replaceOpenAgentIds(["a-hidden", "b-hidden", "z-visible"]);
world.openedEarlier(["a-hidden", "b-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed();
@@ -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);
@@ -202,14 +215,16 @@ class TimelineWorld {
},
});
private openAgentIds = new Set<string>();
show(sourceId: string, agentIds: string[]): void {
for (const id of agentIds) this.openAgentIds.add(id);
this.sync.replaceOpenAgentIds([...this.openAgentIds]);
this.sync.replaceVisibleAgentIds(sourceId, agentIds);
}
/** Chats the user opened earlier in this session and has since navigated away from. */
openedEarlier(agentIds: string[]): void {
this.sync.replaceVisibleAgentIds("earlier", agentIds);
this.sync.replaceVisibleAgentIds("earlier", []);
}
private readonly memberships: MembershipRequest[] = [];
private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = [];
private readonly fetches: TimelineFetch[] = [];
@@ -434,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);
@@ -548,6 +617,8 @@ test("an eviction starts and acknowledges B before A returns its late subscripti
world.sync.setConnected(true);
await vi.waitFor(() => expect(membershipRequests()).toHaveLength(1));
const a = membershipRequests()[0]!;
// Closing the five tabs releases them; opening a sixth chat is what commits the swap.
world.sync.replaceOpenTabAgentIds(["agent-f"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
await vi.waitFor(() => expect(membershipRequests()).toHaveLength(2));
const b = membershipRequests()[1]!;
@@ -585,10 +656,30 @@ test("an eviction starts and acknowledges B before A returns its late subscripti
}
});
test("a restored workspace layout subscribes nothing until the user opens a chat", async () => {
const world = new TimelineWorld();
// Launch. Workspace layout rehydrates from disk carrying a tab for every chat the user
// has ever opened on this host; none of them is open in this session yet.
world.sync.replaceOpenTabAgentIds(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]);
world.sync.setConnected(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
// The workspace the app restores into shows one chat. Only that chat is fetched, so only
// that agent is resumed on the daemon.
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
expect(membership.agentIds).toEqual(["agent-a"]);
membership.succeed();
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
world.expectNoPendingFetch();
world.sync.dispose();
});
test("open chats remain subscribed beyond five views and without mounted workspace panes", async () => {
const world = new TimelineWorld();
const agents = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"];
world.sync.replaceOpenAgentIds(agents);
world.openedEarlier(agents);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
@@ -605,7 +696,7 @@ test("open chats remain subscribed beyond five views and without mounted workspa
world.expectNoPendingFetch();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
world.sync.replaceOpenAgentIds(agents.slice(0, 5));
world.sync.replaceOpenTabAgentIds(agents.slice(0, 5));
const closed = await world.nextMembership();
expect(closed.agentIds).toEqual(agents.slice(0, 5));
closed.succeed();
@@ -635,18 +726,19 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
world.expectNoPendingFetch();
});
test("navigation while disconnected reconnects only the currently visible agent", async () => {
test("navigation while disconnected keeps both opened chats for reconnect", async () => {
const world = new TimelineWorld();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
expect(membership.agentIds).toEqual(["agent-a", "agent-b"]);
membership.succeed();
const catchUp = await world.nextFetch("agent-b");
catchUp.respond({ hasNewer: false });
expect(membership.agentIds).toEqual(["agent-b"]);
// A dropped socket is not the user closing a chat, so reconnect owes both of them a
// catch-up. The visible one goes first.
(await world.nextFetch("agent-b")).respond({ hasNewer: false });
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
world.expectNoPendingFetch();
});
@@ -1028,26 +1120,31 @@ test("visible agents stay subscribed even before open-tab membership arrives", a
expect(membership.agentIds).toEqual(visible);
// Hiding a pane is not closing its chat, so the sixth stays subscribed.
world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5));
const hiddenEviction = await world.nextMembership();
hiddenEviction.succeed();
expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5));
world.expectNoPendingMembership();
// Closing its tab is what releases it.
world.sync.replaceOpenTabAgentIds(visible.slice(0, 5));
const closed = await world.nextMembership();
closed.succeed();
expect(closed.agentIds).toEqual(visible.slice(0, 5));
});
test("disconnect restores visible demand and revisiting a closed chat fetches again", async () => {
test("reconnect restores open demand and reopening a closed chat fetches again", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const agentAMembership = await world.nextMembership();
agentAMembership.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
// Close agent-a's tab, then open agent-b.
world.sync.replaceOpenTabAgentIds(["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentBMembership = await world.nextMembership();
agentBMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
agentBCatchUp.respond({ hasNewer: false });
(await world.nextFetch("agent-b")).respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.sync.setConnected(false);
@@ -1055,17 +1152,14 @@ test("disconnect restores visible demand and revisiting a closed chat fetches ag
world.sync.setConnected(true);
const restored = await world.nextMembership();
restored.succeed();
const restoredCatchUp = await world.nextFetch("agent-b");
restoredCatchUp.respond({ hasNewer: false });
(await world.nextFetch("agent-b")).respond({ hasNewer: false });
expect(restored.agentIds).toEqual(["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const reopened = await world.nextMembership();
reopened.succeed();
const reopenedCatchUp = await world.nextFetch("agent-a");
reopenedCatchUp.respond({ hasNewer: false });
expect(reopened.agentIds).toEqual(["agent-a"]);
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]);
});
test("disposing a view releases its pending observation before bootstrap completes", async () => {
@@ -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;
}
@@ -332,7 +334,12 @@ export interface ViewedTimelineUiBridge {
}
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
replaceOpenAgentIds(agentIds: string[]): void;
/**
* The agent tabs that currently exist in workspace layout. This only ever *releases*
* subscriptions: closing a tab drops its chat. It never adds one, because layout is
* restored from disk at launch and those tabs are chats the user opened days ago.
*/
replaceOpenTabAgentIds(agentIds: string[]): void;
setActive(active: boolean): void;
setConnected(connected: boolean): void;
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
@@ -341,7 +348,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
export type ViewedTimelineOwnerPorts = Omit<
ViewedTimelineSyncPorts,
"prepare" | "replaceDemandedAgentIds"
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
>;
export interface ViewedTimelineOwner extends ViewedTimelineSync {
@@ -371,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,
@@ -495,10 +504,21 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let membershipNeedsRetry = false;
let membershipRetryDelayMs: number | undefined;
let cancelMembershipRetry: (() => void) | null = null;
let openAgentIds: string[] = [];
// Chats this session has opened. Visibility is the only way in, so an agent is never
// subscribed — and never resumed on the daemon — until the user actually opens it.
// Membership then survives hiding, workspace switches, backgrounding and reconnect;
// only closing the tab or ending the session takes it out.
let openedAgentIds: string[] = [];
const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat());
const rememberOpenedAgentIds = (agentIds: string[]) => {
if (agentIds.length === 0) return;
const next = normalizeAgentIds([...openedAgentIds, ...agentIds]);
if (sameAgentIds(next, openedAgentIds)) return;
openedAgentIds = next;
};
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
const isDesired = (agentId: string) => desired.includes(agentId);
const canCatchUp = (agentId: string) =>
@@ -514,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();
};
@@ -769,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) {
@@ -790,12 +813,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
desired = nextDesired;
ports.replaceDemandedAgentIds(desired);
membershipGeneration += 1;
for (const agentId of released) ports.onCatchUpEnded(agentId);
notifyListeners();
void reconcileMembership();
};
const publishVisibleMembership = () => {
commitDesiredMembership(normalizeAgentIds([...openAgentIds, ...visibleAgentIds()]));
commitDesiredMembership(normalizeAgentIds([...openedAgentIds, ...visibleAgentIds()]));
};
return {
@@ -812,16 +836,18 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
getAgentTimelineError(agentId) {
return visibilityCatchUpErrors.get(agentId) ?? null;
},
replaceOpenAgentIds(agentIds) {
const next = normalizeAgentIds(agentIds);
if (sameAgentIds(next, openAgentIds)) return;
openAgentIds = next;
replaceOpenTabAgentIds(agentIds) {
const openTabs = new Set(agentIds);
const next = openedAgentIds.filter((agentId) => openTabs.has(agentId));
if (sameAgentIds(next, openedAgentIds)) return;
openedAgentIds = next;
publishVisibleMembership();
},
replaceVisibleAgentIds(sourceId, agentIds) {
const normalized = normalizeAgentIds(agentIds);
if (normalized.length === 0) sources.delete(sourceId);
else sources.set(sourceId, normalized);
rememberOpenedAgentIds(normalized);
publishVisibleMembership();
startAcknowledgedCatchUps();
},
@@ -879,7 +905,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
desired = [];
ports.replaceDemandedAgentIds([]);
acknowledged = [];
openAgentIds = [];
openedAgentIds = [];
loadedCache.clear();
cacheLoads.clear();
visibilityCatchUpPending.clear();
@@ -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.1",
"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.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/server": "0.9.0-beta.1",
"@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.1",
"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.1",
"@getpaseo/relay": "0.9.0-beta.1",
"@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.1",
"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.1",
"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.1",
"version": "0.9.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.0-beta.1",
"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.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@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.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@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.1",
"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.1",
"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.1",
"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.1",
"@getpaseo/highlight": "0.9.0-beta.1",
"@getpaseo/plugin": "0.9.0-beta.1",
"@getpaseo/protocol": "0.9.0-beta.1",
"@getpaseo/relay": "0.9.0-beta.1",
"@getpaseo/client": "0.9.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",
@@ -80,3 +80,63 @@ test("loads archived records for history and active records with the interactive
await rm(root, { recursive: true, force: true });
}
});
test("resuming a stored agent keeps its unread flag and its last-activity time", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-resume-"));
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
const agentId = "00000000-0000-4000-8000-000000000401";
const lastActive = "2026-01-02T03:04:05.000Z";
const markedUnread = "2026-01-09T03:04:05.000Z";
try {
const agent = await manager.createAgent({ provider: "codex", cwd: root }, agentId, {
workspaceId: "workspace-a",
});
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
const stored = await storage.get(agentId);
if (!stored) {
throw new Error("expected a stored agent");
}
// The agent finished days ago, and was marked unread later without being opened, which
// moves `updatedAt` on its own. Clients already hold that newer time, and
// `acceptAgentDirectoryUpdate` drops anything older, so the resumed agent must not come
// back carrying only `lastActivityAt`.
await storage.upsert({
...stored,
updatedAt: markedUnread,
lastActivityAt: lastActive,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: lastActive,
});
await ensureAgentLoaded(agentId, { agentManager: manager, agentStorage: storage, logger });
await manager.flush();
await storage.flush();
// Loading the runtime is neither the agent working nor the user reading the chat.
// Forging either rewrites the workspace's "last used" and drops it out of Ready to review.
const resumed = await storage.get(agentId);
expect(resumed?.requiresAttention).toBe(true);
expect(resumed?.attentionReason).toBe("finished");
expect(resumed?.updatedAt).toBe(markedUnread);
expect(resumed?.lastActivityAt).toBe(markedUnread);
expect(manager.getAgent(agentId)?.attention.requiresAttention).toBe(true);
expect(manager.getAgent(agentId)?.updatedAt.toISOString()).toBe(markedUnread);
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
@@ -6,6 +6,7 @@ import type { AgentStorage } from "./agent-storage.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractAttention,
extractTimestamps,
isStoredAgentProviderAvailable,
toAgentPersistenceHandle,
@@ -109,11 +110,14 @@ export async function ensureAgentLoaded(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
{ ...extractTimestamps(record), attention: extractAttention(record) },
record.archivedAt ? { purpose: "history" } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
} else {
// No provider handle to resume: this starts the agent's first session rather than
// bringing one back, so it stamps activity and carries no stored attention. Records
// without a handle never got far enough to accumulate either.
const config = buildSessionConfig(record, {
validProviders,
});
@@ -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;
@@ -89,6 +89,7 @@ import {
type ProviderSubagentStoreEvent,
} from "./provider-subagents/store.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -340,7 +341,7 @@ export interface WaitForAgentStartOptions {
signal?: AbortSignal;
}
type AttentionState =
export type AttentionState =
| { requiresAttention: false }
| {
requiresAttention: true;
@@ -1283,6 +1284,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
@@ -1314,6 +1316,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
@@ -1370,6 +1373,7 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
persistence: handle,
restoring: true,
});
}
@@ -1559,6 +1563,7 @@ export class AgentManager {
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
restoring: true,
});
} catch (error) {
if (closedExisting) {
@@ -1838,14 +1843,7 @@ export class AgentManager {
private dispatchStoredAgentState(record: StoredAgentRecord): void {
const updatedAt = new Date(record.updatedAt);
const attention: AttentionState =
record.requiresAttention && record.attentionReason && record.attentionTimestamp
? {
requiresAttention: true,
attentionReason: record.attentionReason,
attentionTimestamp: new Date(record.attentionTimestamp),
}
: { requiresAttention: false };
const attention = extractAttention(record);
this.dispatch({
type: "agent_state",
agent: {
@@ -1936,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);
@@ -3414,6 +3418,12 @@ export class AgentManager {
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
/**
* Bringing a known agent back, rather than starting a new one. Its timestamps and
* attention come from what was already recorded, and installing the session is not
* activity in it.
*/
restoring?: boolean;
initialTitle?: string | null;
publishWhenReady?: boolean;
workspaceId?: string;
@@ -3467,7 +3477,12 @@ export class AgentManager {
await this.refreshSessionState(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
managed.lifecycle = "idle";
this.touchUpdatedAt(managed);
// Stamping now over a restored timestamp rewrote the workspace's "last used" in the
// sidebar every time a chat was reopened, because workspace `statusEnteredAt` is
// re-derived from persisted agent `updatedAt` on every daemon start.
if (!options?.restoring) {
this.touchUpdatedAt(managed);
}
await this.persistSnapshot(managed);
this.assertAgentRegistrationActive(managed);
this.emitState(managed, { persist: false });
@@ -19,7 +19,11 @@ import type {
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "../persistence-hooks.js";
import {
isStoredAgentProviderAvailable,
resolveStoredAgentUpdatedAt,
toAgentPersistenceHandle,
} from "../persistence-hooks.js";
export type { ManagedAgent };
interface ProjectionOptions {
@@ -208,7 +212,7 @@ export function buildStoredAgentPayload(
} as const;
const createdAt = new Date(record.createdAt);
const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record));
const updatedAt = new Date(resolveStoredAgentUpdatedAt(record));
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const runtimeInfo = buildStoredRuntimeInfo(record);
@@ -287,23 +291,6 @@ export function toRecentProviderSessionDescriptorPayload(
};
}
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
const timestamps = [record.updatedAt, record.lastActivityAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map((value) => ({
raw: value,
parsed: Date.parse(value),
}))
.filter((value) => !Number.isNaN(value.parsed));
if (timestamps.length === 0) {
return record.updatedAt;
}
timestamps.sort((a, b) => b.parsed - a.parsed);
return timestamps[0].raw;
}
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (config.modeId) {
@@ -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);
});

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