mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224d3d7e17 | ||
|
|
896cd76d67 | ||
|
|
d161b5987d | ||
|
|
90737e1dec | ||
|
|
3eaf0be0b2 | ||
|
|
8269a0ea78 | ||
|
|
5303313632 | ||
|
|
81ad00790f | ||
|
|
290306fd16 | ||
|
|
3a36cc33c6 | ||
|
|
17c5050216 | ||
|
|
5db3bc82fd | ||
|
|
3dbbbb535e | ||
|
|
a3fc59e818 | ||
|
|
bc5bc969c8 | ||
|
|
8186585201 | ||
|
|
eb665a55a8 | ||
|
|
d22737c77a | ||
|
|
aeb98f813a | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd | ||
|
|
7f7e60bcbb | ||
|
|
8b6e9447f4 | ||
|
|
83f9fba209 | ||
|
|
b2ce2bcb83 | ||
|
|
135a3b4c9e | ||
|
|
2c8e8a8268 | ||
|
|
ee7949ae25 | ||
|
|
d636abd7a4 |
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
+11
-5
@@ -1,14 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0-beta.2 - 2026-09-18
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed every workspace showing the restart time as its last activity after relaunching ([#5040](https://github.com/getpaseo/paseo/pull/5040))
|
||||
- Fixed finished chats dropping out of Ready to review after a restart ([#5040](https://github.com/getpaseo/paseo/pull/5040))
|
||||
- Fixed launch resuming a provider session for every chat ever opened on the host ([#5040](https://github.com/getpaseo/paseo/pull/5040))
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0-beta.1 - 2026-09-17
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -80,6 +84,8 @@
|
||||
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
|
||||
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
|
||||
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
|
||||
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
|
||||
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
|
||||
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
|
||||
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援するか、月 500 ドルで[スポンサー枠を購入](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)して、貴社のロゴをここと [paseo.sh のホームページ](https://paseo.sh/sponsor#spot)に掲載できます。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
|
||||
@@ -161,6 +161,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원하거나, 월 500달러에 [스폰서 자리를 구매](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)하여 회사 로고를 이곳과 [paseo.sh 홈페이지](https://paseo.sh/sponsor#spot)에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
|
||||
@@ -193,6 +193,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra), or [sponsor a spot](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00) for $500 a month to put your company's logo here and on the [paseo.sh homepage](https://paseo.sh/sponsor#spot).
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作,或者以每月 500 美元[赞助一个展示位](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00),把贵公司的 logo 放在这里和 [paseo.sh 首页](https://paseo.sh/sponsor#spot)上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -86,6 +86,13 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
|
||||
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
||||
view renders it without a toast timer or a separate resume workflow.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
first page leaves a chat this client just created looking hydrated but still catching up, which
|
||||
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
|
||||
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
|
||||
one; disconnect and backgrounding keep it.
|
||||
|
||||
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
|
||||
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
|
||||
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-4z83Lg/QGO5amZfxfYjQL5yXjU0GB9jnFjzyZ5pyCPI=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37262,12 +37262,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/server": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37514,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37528,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37772,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38668,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38697,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38714,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38737,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38764,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,16 +38982,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/highlight": "0.9.0-beta.2",
|
||||
"@getpaseo/plugin": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39895,7 +39895,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -38,6 +38,7 @@ import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
recordPanelToasts,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
@@ -1165,8 +1166,11 @@ test.describe("Agent message submission", () => {
|
||||
draftCreateScenario,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserContext } from "@playwright/test";
|
||||
import type { BrowserContext, Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
}
|
||||
|
||||
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
||||
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
||||
}
|
||||
|
||||
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
tag: "code" | "strong",
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
for (const [tag, text] of [
|
||||
["strong", "strong prose"],
|
||||
["em", "emphasized prose"],
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
|
||||
interface SeededProject {
|
||||
projectKey: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project whose directory name ends in a space, which `seedWorkspace`
|
||||
* cannot produce because `mkdtemp` appends the random suffix. The project is
|
||||
* owned until `cleanup`, which removes the daemon record before the directory.
|
||||
*/
|
||||
async function seedProjectNamedWithTrailingSpace(): Promise<SeededProject> {
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-"));
|
||||
const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `);
|
||||
await mkdir(directoryPath);
|
||||
await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n");
|
||||
|
||||
const client = await connectSeedClient();
|
||||
const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true });
|
||||
try {
|
||||
const added = await client.addProject(directoryPath);
|
||||
expect(added.error).toBeNull();
|
||||
expect(added.project?.projectRootPath).toBe(directoryPath);
|
||||
const project = added.project;
|
||||
if (!project?.projectKey) {
|
||||
throw new Error(`The daemon added ${directoryPath} without a project key`);
|
||||
}
|
||||
return {
|
||||
projectKey: project.projectKey,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProjectRow(page: Page, projectKey: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function expectAppShellRendered(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0);
|
||||
}
|
||||
|
||||
// A project view key carries the project's path, so a directory whose name ends
|
||||
// in a space produces a key that ends in a space. The sidebar order store used
|
||||
// to trim it on write, which left the reconcile effect writing a new order on
|
||||
// every render until React tore the app down with "Maximum update depth
|
||||
// exceeded" on every load (#4880).
|
||||
test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => {
|
||||
const project = await seedProjectNamedWithTrailingSpace();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectProjectRow(page, project.projectKey);
|
||||
await expectAppShellRendered(page);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export interface MockAgentOptions {
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export async function seedMockAgentWorkspace(
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "e2e-fast-stream",
|
||||
thinkingOptionId: options.thinkingOptionId,
|
||||
initialPrompt: options.initialPrompt,
|
||||
featureValues: options.featureValues,
|
||||
});
|
||||
|
||||
@@ -111,6 +111,50 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__shownPanelToasts?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PanelToastLog {
|
||||
/** Test ids of every toast that entered the DOM since recording started, in order. */
|
||||
shown(): Promise<string[]>;
|
||||
expectNeverShown(testId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record every toast that enters the DOM from the next navigation onward. A toast can appear
|
||||
* and disappear within a few frames, so a settled `toHaveCount(0)` cannot prove one never showed.
|
||||
*/
|
||||
export async function recordPanelToasts(page: Page): Promise<PanelToastLog> {
|
||||
await page.addInitScript(() => {
|
||||
const shown: string[] = [];
|
||||
window.__shownPanelToasts = shown;
|
||||
const record = (node: Element) => {
|
||||
if (node.getAttribute("role") !== "alert") return;
|
||||
shown.push(node.getAttribute("data-testid") ?? "");
|
||||
};
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
record(node);
|
||||
for (const descendant of node.querySelectorAll('[role="alert"]')) record(descendant);
|
||||
}
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
const shown = () => page.evaluate(() => window.__shownPanelToasts ?? []);
|
||||
return {
|
||||
shown,
|
||||
async expectNeverShown(testId: string): Promise<void> {
|
||||
expect(await shown()).not.toContain(testId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,3 +6,7 @@ export function ChatFind({ children }: ChatFindProps) {
|
||||
export function ChatFindExpansion({ children }: ChatFindExpansionProps) {
|
||||
return children(false);
|
||||
}
|
||||
/** Native has no chat find, so no message is ever selected by one. */
|
||||
export function useChatFindSelectedMessageId(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
@@ -19,11 +19,16 @@ import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
|
||||
import { ChatFindModel } from "./model";
|
||||
import { ChatFindModel, type ChatFindFailure } from "./model";
|
||||
import { createFindViewport } from "./viewport.web";
|
||||
import type { ChatFindProps, ChatFindExpansionProps } from "./types";
|
||||
|
||||
const Expansion = createContext<string | null>(null);
|
||||
const SelectedMessage = createContext<string | null>(null);
|
||||
const FAILURE_TEXT: Record<ChatFindFailure, string> = {
|
||||
connection: "paneFind.connectionFailure",
|
||||
historyChanged: "paneFind.historyChangedFailure",
|
||||
reveal: "paneFind.revealFailure",
|
||||
};
|
||||
const WIDGET_DATASET = { chatFindWidget: "true" };
|
||||
const ROOT_STYLE = {
|
||||
display: "flex",
|
||||
@@ -34,8 +39,17 @@ const ROOT_STYLE = {
|
||||
} as const;
|
||||
let nextHighlightId = 0;
|
||||
|
||||
export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) {
|
||||
return children(useContext(Expansion) === itemId);
|
||||
/**
|
||||
* The message chat find has landed on, for surfaces that must keep it addressable:
|
||||
* the row that lifts its render cap, and the web virtualizer that has to mount every
|
||||
* block row of it so the whole message can be counted and highlighted.
|
||||
*/
|
||||
export function useChatFindSelectedMessageId(): string | null {
|
||||
return useContext(SelectedMessage);
|
||||
}
|
||||
|
||||
export function ChatFindExpansion({ messageId, children }: ChatFindExpansionProps) {
|
||||
return children(useChatFindSelectedMessageId() === messageId);
|
||||
}
|
||||
|
||||
export function ChatFind({
|
||||
@@ -44,15 +58,15 @@ export function ChatFind({
|
||||
epoch,
|
||||
items,
|
||||
viewportRef,
|
||||
revealLoadedItem,
|
||||
visibleItemIds,
|
||||
revealLoadedMessage,
|
||||
visibleMessageIds,
|
||||
children,
|
||||
}: ChatFindProps) {
|
||||
const { t } = useTranslation();
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const bindings = useRef({ viewportRef, revealLoadedItem, visibleItemIds });
|
||||
bindings.current = { viewportRef, revealLoadedItem, visibleItemIds };
|
||||
const bindings = useRef({ viewportRef, revealLoadedMessage, visibleMessageIds });
|
||||
bindings.current = { viewportRef, revealLoadedMessage, visibleMessageIds };
|
||||
const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`);
|
||||
const { isInteractive } = usePaneFocus();
|
||||
const active = useRetainedPanelActive();
|
||||
@@ -92,12 +106,7 @@ export function ChatFind({
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
model.open();
|
||||
widget.current?.focus();
|
||||
@@ -117,12 +126,12 @@ export function ChatFind({
|
||||
else if (state.phase === "error") status = t("paneFind.failed");
|
||||
else if (state.query.trim())
|
||||
status = state.count
|
||||
? t("paneFind.chatPosition", { current: state.occurrence + 1, total: state.count })
|
||||
? t("paneFind.position", { current: state.occurrence + 1, total: state.count })
|
||||
: t("paneFind.noMatches");
|
||||
return (
|
||||
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
|
||||
<style>{`::highlight(${highlightName}) { background-color: ${styles.highlight.backgroundColor}; color: ${styles.highlight.color}; }`}</style>
|
||||
<Expansion.Provider value={state.selectedItemId}>{children}</Expansion.Provider>
|
||||
<SelectedMessage.Provider value={state.selectedItemId}>{children}</SelectedMessage.Provider>
|
||||
{state.open && (
|
||||
<View style={styles.overlay} dataSet={WIDGET_DATASET}>
|
||||
<PaneFind
|
||||
@@ -135,9 +144,9 @@ export function ChatFind({
|
||||
onPrevious={model.previous}
|
||||
onClose={close}
|
||||
/>
|
||||
{state.error && (
|
||||
{state.failure && (
|
||||
<View style={styles.error}>
|
||||
<Text style={styles.errorText}>{t("paneFind.searchFailed")}</Text>
|
||||
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
|
||||
<Button size="xs" variant="ghost" onPress={model.retry}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
||||
import { hydrateStreamState, type StreamItem } from "@/types/stream";
|
||||
import { ChatFindModel, type ChatFindOperations } from "./model";
|
||||
|
||||
function response(locations: Array<{ seq: number; role: "user" | "assistant" }>) {
|
||||
function response(locations: Array<{ seq: number; role: "user" | "assistant"; count?: number }>) {
|
||||
return {
|
||||
agentId: "agent",
|
||||
requestId: "request",
|
||||
@@ -159,11 +159,60 @@ it("retains a failed search for retry and distinguishes failure from zero matche
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("error");
|
||||
expect(model.getSnapshot().error).toBe("Host disconnected");
|
||||
expect(model.getSnapshot().failure).toBe("connection");
|
||||
disconnected = false;
|
||||
model.retry();
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ count: 0, error: null });
|
||||
expect(model.getSnapshot()).toMatchObject({ count: 0, failure: null });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
// `load` is a host RPC like `search`, so its failure is a connection failure and the
|
||||
// widget must say so rather than blaming the reveal.
|
||||
it("reports a failed timeline load as a connection failure", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([{ seq: 1, role: "assistant" }]);
|
||||
},
|
||||
async load() {
|
||||
throw new Error("Host disconnected");
|
||||
},
|
||||
async reveal() {
|
||||
return { count: 1, occurrence: 0 };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", []);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("error");
|
||||
expect(model.getSnapshot().failure).toBe("connection");
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a search result that outlived its epoch as a changed history", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([{ seq: 1, role: "assistant" }]);
|
||||
},
|
||||
async load() {},
|
||||
async reveal() {
|
||||
return { count: 1, occurrence: 0 };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
model.updateHistory("epoch-2", [message(1)]);
|
||||
expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
@@ -187,7 +236,77 @@ it("can load a search location before the initial chat history has arrived", asy
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null });
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, failure: null });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The scope of the search is the whole chat, so the position spans every located
|
||||
// message: the host's estimate until a message is on screen, the rendered count after.
|
||||
it("reports the position across the whole chat and corrects it with rendered counts", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([
|
||||
{ seq: 1, role: "assistant", count: 2 },
|
||||
{ seq: 2, role: "assistant", count: 3 },
|
||||
]);
|
||||
},
|
||||
async load() {},
|
||||
async reveal(id, _query, occurrence) {
|
||||
const count = id === "row-1" ? 2 : 1;
|
||||
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 5 });
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
|
||||
expect(model.getSnapshot().count).toBe(5);
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
|
||||
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
|
||||
model.previous();
|
||||
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-1");
|
||||
expect(model.getSnapshot()).toMatchObject({ occurrence: 1, count: 3 });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
// COMPAT(timelineSearchCount): a host that reports no counts is worth one match per
|
||||
// located message until a reveal replaces the estimate with the rendered count.
|
||||
it("counts one match per message from a host that reports no counts", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([
|
||||
{ seq: 1, role: "assistant" },
|
||||
{ seq: 2, role: "assistant" },
|
||||
]);
|
||||
},
|
||||
async load() {},
|
||||
async reveal(id, _query, occurrence) {
|
||||
const count = id === "row-1" ? 2 : 1;
|
||||
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 3 });
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
|
||||
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
type Location = AgentTimelineSearchPayload["locations"][number];
|
||||
/**
|
||||
* A message the host says contains the query. `count` is the host's estimate of its
|
||||
* occurrences until a reveal replaces it with the rendered count, so the whole-chat
|
||||
* position the widget shows is exact for every message that has been on screen. The
|
||||
* wire field is optional; the search boundary fills it in, so the interior trusts it.
|
||||
*/
|
||||
type Location = Omit<AgentTimelineSearchPayload["locations"][number], "count"> & {
|
||||
count: number;
|
||||
};
|
||||
interface Target {
|
||||
id: string;
|
||||
seq: number;
|
||||
before: number;
|
||||
}
|
||||
/**
|
||||
* Why a search stopped, so the widget can say something true. `connection` is the
|
||||
* host call itself failing, `historyChanged` is the timeline moving under a result
|
||||
* set, and `reveal` is a located match that never made it onto the screen.
|
||||
*/
|
||||
export type ChatFindFailure = "connection" | "historyChanged" | "reveal";
|
||||
|
||||
class ChatFindFailureError extends Error {
|
||||
constructor(
|
||||
readonly failure: ChatFindFailure,
|
||||
options?: { cause: unknown },
|
||||
) {
|
||||
super(`Chat find failed: ${failure}`, options);
|
||||
}
|
||||
}
|
||||
|
||||
function tagged<T>(failure: ChatFindFailure, operation: Promise<T>): Promise<T> {
|
||||
return operation.catch((cause: unknown) => {
|
||||
throw cause instanceof ChatFindFailureError
|
||||
? cause
|
||||
: new ChatFindFailureError(failure, { cause });
|
||||
});
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
open: boolean;
|
||||
query: string;
|
||||
phase: "idle" | "searching" | "loading" | "ready" | "error";
|
||||
selectedItemId: string | null;
|
||||
/** Position across the whole chat: the search scope, not the selected message. */
|
||||
occurrence: number;
|
||||
count: number;
|
||||
error: string | null;
|
||||
failure: ChatFindFailure | null;
|
||||
}
|
||||
export interface ChatFindOperations {
|
||||
search(query: string, cursor?: number): Promise<AgentTimelineSearchPayload>;
|
||||
load(epoch: string, seq: number): Promise<unknown>;
|
||||
reveal(
|
||||
itemId: string,
|
||||
messageId: string,
|
||||
query: string,
|
||||
occurrence: number,
|
||||
signal: AbortSignal,
|
||||
@@ -36,7 +69,7 @@ export class ChatFindModel {
|
||||
selectedItemId: null,
|
||||
occurrence: 0,
|
||||
count: 0,
|
||||
error: null,
|
||||
failure: null,
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private historyListeners = new Set<() => void>();
|
||||
@@ -46,6 +79,7 @@ export class ChatFindModel {
|
||||
private locations: Location[] = [];
|
||||
private resolved = new Map<Location, Target>();
|
||||
private current = -1;
|
||||
private inMessage = { occurrence: 0, count: 0 };
|
||||
private abort = new AbortController();
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
constructor(private operations: ChatFindOperations) {}
|
||||
@@ -73,7 +107,7 @@ export class ChatFindModel {
|
||||
};
|
||||
readonly close = () => {
|
||||
this.cancel();
|
||||
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null });
|
||||
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, failure: null });
|
||||
};
|
||||
readonly setQuery = (query: string) => {
|
||||
this.cancel();
|
||||
@@ -81,13 +115,14 @@ export class ChatFindModel {
|
||||
this.resultEpoch = null;
|
||||
this.resolved.clear();
|
||||
this.current = -1;
|
||||
this.inMessage = { occurrence: 0, count: 0 };
|
||||
this.publish({
|
||||
query,
|
||||
phase: query.trim() ? "searching" : "idle",
|
||||
selectedItemId: null,
|
||||
occurrence: 0,
|
||||
count: 0,
|
||||
error: null,
|
||||
failure: null,
|
||||
});
|
||||
if (!query.trim()) return;
|
||||
const signal = this.abort.signal;
|
||||
@@ -102,20 +137,30 @@ export class ChatFindModel {
|
||||
this.publish({
|
||||
phase: "error",
|
||||
selectedItemId: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
// Every operation call site is tagged, so an untagged failure is a bug here
|
||||
// rather than a known outcome; `connection` is the least misleading thing to
|
||||
// say about one, and its copy already asks the reader to check and retry.
|
||||
failure: error instanceof ChatFindFailureError ? error.failure : "connection",
|
||||
});
|
||||
}
|
||||
private async search(signal: AbortSignal) {
|
||||
try {
|
||||
let cursor: number | undefined;
|
||||
do {
|
||||
const result = await this.operations.search(this.state.query, cursor);
|
||||
const result = await tagged("connection", this.operations.search(this.state.query, cursor));
|
||||
if (signal.aborted) return;
|
||||
const historyChanged = this.epoch !== null && result.epoch !== this.epoch;
|
||||
const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch;
|
||||
if (historyChanged || searchChanged) throw new Error("History changed; search again");
|
||||
if (historyChanged || searchChanged) throw new ChatFindFailureError("historyChanged");
|
||||
this.resultEpoch = result.epoch;
|
||||
this.locations.push(...result.locations);
|
||||
this.locations.push(
|
||||
...result.locations.map((location) => ({
|
||||
seq: location.seq,
|
||||
role: location.role,
|
||||
// COMPAT(timelineSearchCount): hosts before v0.9.0 send no count; remove after 2027-09-22.
|
||||
count: location.count ?? 1,
|
||||
})),
|
||||
);
|
||||
cursor = result.nextCursor ?? undefined;
|
||||
} while (cursor !== undefined);
|
||||
await this.navigate(0, 1, signal);
|
||||
@@ -130,7 +175,7 @@ export class ChatFindModel {
|
||||
epoch !== this.resultEpoch &&
|
||||
this.state.open
|
||||
) {
|
||||
this.fail(new Error("History changed; search again"), this.abort.signal);
|
||||
this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal);
|
||||
}
|
||||
this.epoch = epoch;
|
||||
this.items = items;
|
||||
@@ -159,7 +204,7 @@ export class ChatFindModel {
|
||||
this.historyListeners.delete(check);
|
||||
signal.removeEventListener("abort", cancelled);
|
||||
if (target) resolve(target);
|
||||
else reject(new Error("Could not load this search location; retry"));
|
||||
else reject(new ChatFindFailureError("reveal"));
|
||||
};
|
||||
const check = () => {
|
||||
const target = this.target(location, false);
|
||||
@@ -176,7 +221,7 @@ export class ChatFindModel {
|
||||
private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) {
|
||||
const epoch = this.resultEpoch;
|
||||
if (epoch === null) return;
|
||||
this.publish({ phase: "loading", error: null });
|
||||
this.publish({ phase: "loading", failure: null });
|
||||
let remaining = this.locations.length;
|
||||
while (remaining-- > 0 && this.locations.length) {
|
||||
index = (index + this.locations.length) % this.locations.length;
|
||||
@@ -187,7 +232,7 @@ export class ChatFindModel {
|
||||
this.items.some((item) => item.id === cached.id && item.timelineCursor?.seq === cached.seq);
|
||||
let target = stillLoaded ? cached : this.target(location, true);
|
||||
if (!target) {
|
||||
await this.operations.load(epoch, location.seq);
|
||||
await tagged("connection", this.operations.load(epoch, location.seq));
|
||||
if (signal.aborted) return;
|
||||
target = await this.waitForTarget(location, signal);
|
||||
}
|
||||
@@ -204,16 +249,14 @@ export class ChatFindModel {
|
||||
);
|
||||
index = this.locations.indexOf(location);
|
||||
this.publish({ selectedItemId: target.id });
|
||||
const result = await this.operations.reveal(
|
||||
target.id,
|
||||
this.state.query,
|
||||
direction === 1 ? 0 : -1,
|
||||
signal,
|
||||
const result = await tagged(
|
||||
"reveal",
|
||||
this.operations.reveal(target.id, this.state.query, direction === 1 ? 0 : -1, signal),
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
if (result.count) {
|
||||
this.current = index;
|
||||
this.publish({ phase: "ready", ...result });
|
||||
this.publish({ phase: "ready", ...this.position(location, result) });
|
||||
return;
|
||||
}
|
||||
this.locations.splice(index, 1);
|
||||
@@ -222,23 +265,32 @@ export class ChatFindModel {
|
||||
this.operations.clear();
|
||||
this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 });
|
||||
}
|
||||
private position(location: Location, revealed: { occurrence: number; count: number }) {
|
||||
this.inMessage = revealed;
|
||||
location.count = revealed.count;
|
||||
let before = 0;
|
||||
let total = 0;
|
||||
for (const candidate of this.locations) {
|
||||
if (candidate === location) before = total;
|
||||
total += candidate.count;
|
||||
}
|
||||
return { occurrence: before + revealed.occurrence, count: total };
|
||||
}
|
||||
private async move(direction: 1 | -1) {
|
||||
if (this.state.phase !== "ready" || !this.state.count) return;
|
||||
this.cancel();
|
||||
const signal = this.abort.signal;
|
||||
try {
|
||||
const occurrence = this.state.occurrence + direction;
|
||||
if (occurrence >= 0 && occurrence < this.state.count && this.state.selectedItemId) {
|
||||
const occurrence = this.inMessage.occurrence + direction;
|
||||
if (occurrence >= 0 && occurrence < this.inMessage.count && this.state.selectedItemId) {
|
||||
this.publish({ phase: "loading" });
|
||||
const result = await this.operations.reveal(
|
||||
this.state.selectedItemId,
|
||||
this.state.query,
|
||||
occurrence,
|
||||
signal,
|
||||
const result = await tagged(
|
||||
"reveal",
|
||||
this.operations.reveal(this.state.selectedItemId, this.state.query, occurrence, signal),
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
if (result.count) {
|
||||
this.publish({ phase: "ready", ...result });
|
||||
this.publish({ phase: "ready", ...this.position(this.locations[this.current]!, result) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { findRenderedMatches } from "./ranges.web";
|
||||
import { findMessageMatches, findRenderedMatches } from "./ranges.web";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
@@ -10,6 +10,36 @@ function content(html: string) {
|
||||
document.body.append(row);
|
||||
return row;
|
||||
}
|
||||
function message(messageId: string, ...blocks: string[]) {
|
||||
const root = document.createElement("div");
|
||||
for (const block of blocks) {
|
||||
const row = document.createElement("div");
|
||||
row.dataset.messageId = messageId;
|
||||
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${block}</div></div>`;
|
||||
root.append(row);
|
||||
}
|
||||
document.body.append(root);
|
||||
return root;
|
||||
}
|
||||
it("collects a message's occurrences across its block rows in row order", () => {
|
||||
const root = message("message-1", "alpha one", "beta", "alpha two");
|
||||
const other = document.createElement("div");
|
||||
other.dataset.messageId = "message-2";
|
||||
other.innerHTML =
|
||||
'<div data-message-text="true"><div data-paseo-markdown-tag="p">alpha three</div></div>';
|
||||
root.append(other);
|
||||
expect(findMessageMatches(root, "message-1", "alpha").map((range) => range.toString())).toEqual([
|
||||
"alpha",
|
||||
"alpha",
|
||||
]);
|
||||
expect(
|
||||
findMessageMatches(root, "message-1", "alpha").map(
|
||||
(range) => range.startContainer.parentElement?.textContent,
|
||||
),
|
||||
).toEqual(["alpha one", "alpha two"]);
|
||||
expect(findMessageMatches(root, "message-2", "alpha")).toHaveLength(1);
|
||||
expect(findMessageMatches(null, "message-1", "alpha")).toEqual([]);
|
||||
});
|
||||
it("finds each actual occurrence across inline formatting with original Unicode offsets", () => {
|
||||
const row = content(
|
||||
'<div data-paseo-markdown-tag="p">İ😀hello <strong>world</strong> then <em>hello</em> world</div>',
|
||||
|
||||
@@ -4,6 +4,23 @@ const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"
|
||||
const IGNORED =
|
||||
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
|
||||
|
||||
/** The rows of one message, in reading order. Each is one Markdown block. */
|
||||
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Occurrences in one message, ordered by row and then by position inside the row. */
|
||||
export function findMessageMatches(
|
||||
root: HTMLElement | null,
|
||||
messageId: string,
|
||||
query: string,
|
||||
): Range[] {
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
|
||||
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
@@ -8,11 +8,11 @@ export interface ChatFindProps {
|
||||
epoch: string | null;
|
||||
items: StreamItem[];
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
revealLoadedItem(itemId: string): boolean;
|
||||
visibleItemIds: ReadonlySet<string>;
|
||||
revealLoadedMessage(messageId: string): boolean;
|
||||
visibleMessageIds: ReadonlySet<string>;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface ChatFindExpansionProps {
|
||||
itemId: string;
|
||||
messageId: string;
|
||||
children(renderFullContent: boolean): ReactNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createFindViewport } from "./viewport.web";
|
||||
import type { ChatFindProps } from "./types";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
|
||||
interface Capture {
|
||||
occurrence?: { signal: AbortSignal; targetTop(): number | null };
|
||||
}
|
||||
|
||||
function transcript(messageId: string, blocks: string[]): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
for (const [index, text] of blocks.entries()) {
|
||||
const row = document.createElement("div");
|
||||
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
|
||||
row.setAttribute("data-message-id", messageId);
|
||||
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${text}</div></div>`;
|
||||
root.append(row);
|
||||
}
|
||||
document.body.append(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
};
|
||||
const bindings: Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds"> =
|
||||
{
|
||||
viewportRef: { current: handle },
|
||||
revealLoadedMessage: () => false,
|
||||
visibleMessageIds: new Set(["message-1"]),
|
||||
};
|
||||
return createFindViewport({
|
||||
getBindings: () => bindings,
|
||||
getRoot: () => root,
|
||||
highlightName: "paseo-chat-find-test",
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
CSS.highlights.delete("paseo-chat-find-test");
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("counts occurrences across every block row of the message", async () => {
|
||||
const root = transcript("message-1", ["needle one needle", "no hits", "needle three"]);
|
||||
const capture: Capture = {};
|
||||
const found = await viewport(root, capture).reveal(
|
||||
"message-1",
|
||||
"needle",
|
||||
2,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(found).toEqual({ occurrence: 2, count: 3 });
|
||||
});
|
||||
|
||||
// The settle loop asks for the target position every frame while the viewport moves,
|
||||
// and rows mount and unmount as it does. A flat index into the message would name a
|
||||
// different occurrence each time the row set changed; the chosen row owns it instead.
|
||||
it("keeps the chosen occurrence when an earlier row unmounts mid-scroll", async () => {
|
||||
const root = transcript("message-1", ["needle one needle", "needle three"]);
|
||||
const capture: Capture = {};
|
||||
const found = await viewport(root, capture).reveal(
|
||||
"message-1",
|
||||
"needle",
|
||||
2,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(found).toEqual({ occurrence: 2, count: 3 });
|
||||
|
||||
const settled = capture.occurrence?.targetTop();
|
||||
expect(settled).toBeTypeOf("number");
|
||||
expect(highlightedText()).toEqual(["needle"]);
|
||||
expect(highlightedRow()).toBe("message-1:block:1");
|
||||
|
||||
root.firstElementChild?.remove();
|
||||
expect(capture.occurrence?.targetTop()).toBeTypeOf("number");
|
||||
expect(highlightedRow()).toBe("message-1:block:1");
|
||||
});
|
||||
|
||||
function highlightedRanges(): Range[] {
|
||||
const highlight = CSS.highlights.get("paseo-chat-find-test");
|
||||
return highlight ? Array.from(highlight).filter((range) => range instanceof Range) : [];
|
||||
}
|
||||
|
||||
function highlightedText(): string[] {
|
||||
return highlightedRanges().map((range) => range.toString());
|
||||
}
|
||||
|
||||
function highlightedRow(): string | undefined {
|
||||
const row =
|
||||
highlightedRanges()[0]?.startContainer.parentElement?.closest<HTMLElement>(
|
||||
"[data-history-row-id]",
|
||||
);
|
||||
return row?.dataset.historyRowId;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { ChatFindOperations } from "./model";
|
||||
import type { ChatFindProps } from "./types";
|
||||
import { findRenderedMatches } from "./ranges.web";
|
||||
import { findMessageMatches, findMessageRows, findRenderedMatches } from "./ranges.web";
|
||||
|
||||
interface ViewportInput {
|
||||
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedItem" | "visibleItemIds">;
|
||||
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds">;
|
||||
getRoot(): HTMLElement | null;
|
||||
highlightName: string;
|
||||
}
|
||||
|
||||
function rowOf(range: Range): HTMLElement | null {
|
||||
return range.startContainer.parentElement?.closest<HTMLElement>("[data-history-row-id]") ?? null;
|
||||
}
|
||||
|
||||
export function createFindViewport({
|
||||
getBindings,
|
||||
getRoot,
|
||||
@@ -16,7 +21,9 @@ export function createFindViewport({
|
||||
clear() {
|
||||
CSS.highlights.delete(highlightName);
|
||||
},
|
||||
reveal(itemId, query, occurrence, signal) {
|
||||
// A message renders as one row per Markdown block, so an occurrence can sit in
|
||||
// any of them and the chosen one decides where the viewport lands.
|
||||
reveal(messageId, query, occurrence, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let frame = 0;
|
||||
let started = false;
|
||||
@@ -40,23 +47,21 @@ export function createFindViewport({
|
||||
return;
|
||||
}
|
||||
const current = getBindings();
|
||||
if (!current.visibleItemIds.has(itemId)) {
|
||||
current.revealLoadedItem(itemId);
|
||||
if (!current.visibleMessageIds.has(messageId)) {
|
||||
current.revealLoadedMessage(messageId);
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
current.viewportRef.current?.scrollToMessage?.(itemId);
|
||||
current.viewportRef.current?.scrollToMessage?.(messageId);
|
||||
started = true;
|
||||
}
|
||||
const row = getRoot()?.querySelector<HTMLElement>(
|
||||
`[data-history-row-id="${CSS.escape(itemId)}"]`,
|
||||
);
|
||||
if (!row || !row.getBoundingClientRect().height) {
|
||||
const rows = findMessageRows(getRoot(), messageId);
|
||||
if (!rows.some((row) => row.getBoundingClientRect().height)) {
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
const ranges = findRenderedMatches(row, query);
|
||||
const ranges = findMessageMatches(getRoot(), messageId, query);
|
||||
const index =
|
||||
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
|
||||
const range = ranges[index];
|
||||
@@ -65,10 +70,18 @@ export function createFindViewport({
|
||||
resolve({ occurrence: 0, count: 0 });
|
||||
return;
|
||||
}
|
||||
current.viewportRef.current?.scrollToMessage?.(itemId, {
|
||||
// The settle loop re-resolves the range every frame because scrolling
|
||||
// invalidates it. Re-counting the whole message would let a row mounting or
|
||||
// unmounting mid-scroll shift which occurrence a fixed index names, so the
|
||||
// occurrence is pinned to its own row for the rest of this reveal.
|
||||
const row = rowOf(range);
|
||||
const indexInRow = ranges
|
||||
.slice(0, index)
|
||||
.filter((earlier) => rowOf(earlier) === row).length;
|
||||
current.viewportRef.current?.scrollToMessage?.(messageId, {
|
||||
signal,
|
||||
targetTop(currentRow) {
|
||||
const selected = findRenderedMatches(currentRow, query)[index];
|
||||
targetTop() {
|
||||
const selected = row ? findRenderedMatches(row, query)[indexInRow] : undefined;
|
||||
if (!selected) return null;
|
||||
CSS.highlights.set(highlightName, new Highlight(selected));
|
||||
const widget = getRoot()?.querySelector<HTMLElement>(
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("useChatOutline", () => {
|
||||
timelineEpoch,
|
||||
enabled,
|
||||
viewportRef,
|
||||
visibleItemIds: new Set(),
|
||||
visibleMessageIds: new Set(),
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{
|
||||
@@ -236,7 +236,7 @@ describe("useChatOutline", () => {
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const revealLoadedMessage = vi.fn(() => true);
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
@@ -253,7 +253,7 @@ describe("useChatOutline", () => {
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ visibleItemIds }) =>
|
||||
({ visibleMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -263,18 +263,18 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{ initialProps: { visibleItemIds: new Set<string>() } },
|
||||
{ initialProps: { visibleMessageIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(1));
|
||||
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
|
||||
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ visibleItemIds: new Set(["older-prompt"]) });
|
||||
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
@@ -286,7 +286,7 @@ describe("useChatOutline", () => {
|
||||
const fetch = deferred<void>();
|
||||
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const revealLoadedMessage = vi.fn(() => true);
|
||||
const viewportRef = {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
@@ -302,7 +302,7 @@ describe("useChatOutline", () => {
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, visibleItemIds }) =>
|
||||
({ tail, visibleMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -312,13 +312,13 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
visibleItemIds: new Set<string>(),
|
||||
visibleMessageIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -327,11 +327,11 @@ describe("useChatOutline", () => {
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
@@ -355,8 +355,8 @@ describe("useChatOutline", () => {
|
||||
timestamp: new Date(2),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
};
|
||||
const revealLoadedItem = vi.fn();
|
||||
revealLoadedItem.mockReturnValue(false);
|
||||
const revealLoadedMessage = vi.fn();
|
||||
revealLoadedMessage.mockReturnValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
@@ -367,8 +367,8 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds: new Set([livePrompt.id]),
|
||||
revealLoadedItem,
|
||||
visibleMessageIds: new Set([livePrompt.id]),
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleItemIds?: ReadonlySet<string>;
|
||||
revealLoadedItem?: (itemId: string) => boolean;
|
||||
visibleMessageIds?: ReadonlySet<string>;
|
||||
revealLoadedMessage?: (messageId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ChatOutline {
|
||||
@@ -53,8 +53,8 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||
@@ -136,8 +136,8 @@ export function useChatOutline({
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
if (visibleItemIds?.has(target.id) === false) {
|
||||
revealLoadedItem?.(target.id);
|
||||
if (visibleMessageIds?.has(target.id) === false) {
|
||||
revealLoadedMessage?.(target.id);
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(target.id);
|
||||
@@ -148,7 +148,7 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
|
||||
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
@@ -156,7 +156,7 @@ export function useChatOutline({
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedItem?.(loaded.id)) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
@@ -180,7 +180,7 @@ export function useChatOutline({
|
||||
});
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
|
||||
);
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
|
||||
@@ -210,21 +210,113 @@ describe("stream presentation through installed plugins", () => {
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
it("leaves fetched native Markdown intact, including cross-paragraph references", () => {
|
||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
// never leaves an empty row behind and the reference still resolves.
|
||||
it("splits an assistant message into the same blocks through history and through the live head", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("[Link][docs]\n\n[docs]: https://example.com"),
|
||||
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const messageId = source[0]!.id;
|
||||
const present = (placement: "tail" | "head") =>
|
||||
rows(
|
||||
createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
tail: placement === "tail" ? source : [],
|
||||
head: placement === "head" ? source : [],
|
||||
transform: undefined,
|
||||
}),
|
||||
);
|
||||
expect(present("tail")).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "[Link][docs]\n\n[docs]: https://example.com",
|
||||
},
|
||||
{
|
||||
id: `${messageId}:block:1`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 1,
|
||||
text: "Closing paragraph.",
|
||||
},
|
||||
]);
|
||||
const blocks = (items: StreamItem[]) =>
|
||||
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
|
||||
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
|
||||
});
|
||||
|
||||
// Rows are addressed by id from outside presentation, so the same text has to land on
|
||||
// the same ids whether the reader watched it arrive or opened the chat afterwards.
|
||||
it("gives a streamed message the block ids its hydrated text would have had", () => {
|
||||
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
|
||||
const harness = streamHarness();
|
||||
for (let end = 8; end < text.length; end += 8) {
|
||||
harness.send(assistant(text.slice(end - 8, end)));
|
||||
}
|
||||
harness.send(assistant(text.slice(text.length - (text.length % 8 || 8))));
|
||||
const streamed = rows(harness.send({ type: "turn_completed", provider: "claude" }));
|
||||
const hydrated = rows(
|
||||
createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]),
|
||||
head: [],
|
||||
transform: undefined,
|
||||
}),
|
||||
);
|
||||
expect(streamed.map((item) => item.id)).toEqual(hydrated.map((item) => item.id));
|
||||
expect(streamed.map((item) => item.kind === "assistant_message" && item.text)).toEqual(
|
||||
hydrated.map((item) => item.kind === "assistant_message" && item.text),
|
||||
);
|
||||
});
|
||||
|
||||
it("repoints a block at the canonical source that replaced the streamed one", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Streamed answer"));
|
||||
const streamed = rows(harness.render());
|
||||
expect(streamed[0]).not.toHaveProperty("timelineCursor");
|
||||
const source = harness.source();
|
||||
const live = source.head[0];
|
||||
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
|
||||
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
|
||||
const present = createStreamPresentation();
|
||||
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
|
||||
const repointed = rows(
|
||||
present({ ...presentationOptions, tail: [], head: [canonical], transform: undefined }),
|
||||
);
|
||||
expect(repointed).toMatchObject([
|
||||
{
|
||||
id: streamed[0]!.id,
|
||||
text: "Streamed answer",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single-paragraph message a one-block group", () => {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant("Only one paragraph."), timestamp: new Date(1000) },
|
||||
]);
|
||||
const messageId = source[0]!.id;
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toEqual(source);
|
||||
expect(result.tail[0]).toBe(source[0]);
|
||||
expect(result.tail).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "Only one paragraph.",
|
||||
},
|
||||
]);
|
||||
expect(result.tail[0]!.id).not.toBe(messageId);
|
||||
});
|
||||
|
||||
it("continues to stream inline reasoning with a stable plugin row", () => {
|
||||
@@ -441,7 +533,13 @@ describe("timeline presentation", () => {
|
||||
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
|
||||
},
|
||||
];
|
||||
expect(projectTimelineItems(items)).toEqual(items);
|
||||
expect(projectTimelineItems(items)).toEqual(
|
||||
items.map((item) =>
|
||||
item.kind === "assistant_message"
|
||||
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
expect(projectTimelineItems(items)[0]).toBe(items[0]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BackHandler, Platform } from "react-native";
|
||||
import type { BackPressSource } from "./visibility-tracker";
|
||||
|
||||
/**
|
||||
* Android's hardware Back press.
|
||||
*
|
||||
* iOS and web have no such press, and `react-native-web`'s `BackHandler` logs an error as soon as
|
||||
* anything subscribes, so nothing is registered off Android.
|
||||
*/
|
||||
export const systemBackPress: BackPressSource = {
|
||||
subscribe(onBackPress) {
|
||||
if (Platform.OS !== "android") return () => {};
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
||||
return () => subscription.remove();
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import { systemBackPress } from "./back-press";
|
||||
import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
@@ -85,7 +86,11 @@ export function useIsolatedBottomSheetVisibility({
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
const tracker = useMemo(
|
||||
() => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }),
|
||||
() =>
|
||||
createBottomSheetVisibilityTracker({
|
||||
onClose: () => onCloseRef.current(),
|
||||
backPress: systemBackPress,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BackPressSource,
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
} from "./visibility-tracker";
|
||||
@@ -20,21 +21,55 @@ class FakeBottomSheet implements BottomSheetController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for Android's `BackHandler`, which offers the press to listeners newest-first and
|
||||
* stops at the first one that claims it.
|
||||
*/
|
||||
class FakeBackPress implements BackPressSource {
|
||||
private listeners: (() => boolean)[] = [];
|
||||
|
||||
subscribe(onBackPress: () => boolean): () => void {
|
||||
this.listeners.push(onBackPress);
|
||||
return () => {
|
||||
const index = this.listeners.indexOf(onBackPress);
|
||||
if (index >= 0) this.listeners.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a sheet claimed the press, false when it falls through to the navigator. */
|
||||
press(): boolean {
|
||||
for (let index = this.listeners.length - 1; index >= 0; index--) {
|
||||
if (this.listeners[index]?.()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const backPress = new FakeBackPress();
|
||||
let closeCount = 0;
|
||||
const tracker = createBottomSheetVisibilityTracker({
|
||||
onClose: () => {
|
||||
closeCount += 1;
|
||||
},
|
||||
backPress,
|
||||
});
|
||||
return {
|
||||
sheet,
|
||||
backPress,
|
||||
tracker,
|
||||
closeCount: () => closeCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a sheet and runs it through the presentation the real sheet reports back. */
|
||||
function openSheet(context: ReturnType<typeof setup>) {
|
||||
context.tracker.attachController(context.sheet);
|
||||
context.tracker.syncDesired({ visible: true });
|
||||
context.tracker.handleSheetIndexChange(0);
|
||||
}
|
||||
|
||||
describe("bottom sheet visibility tracker", () => {
|
||||
it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
@@ -160,4 +195,53 @@ describe("bottom sheet visibility tracker", () => {
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
|
||||
});
|
||||
|
||||
it("dismisses the sheet on a Back press instead of letting it reach the navigator", () => {
|
||||
const context = setup();
|
||||
openSheet(context);
|
||||
|
||||
expect(context.backPress.press()).toBe(true);
|
||||
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
|
||||
const context = setup();
|
||||
context.tracker.attachController(context.sheet);
|
||||
context.tracker.syncDesired({ visible: false });
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
|
||||
openSheet(context);
|
||||
context.tracker.handleSheetIndexChange(-1);
|
||||
context.tracker.handleSheetDismiss();
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
});
|
||||
|
||||
it("gives the Back press to the sheet presented last", () => {
|
||||
const backPress = new FakeBackPress();
|
||||
const sheets = ["below", "above"].map((name) => {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const tracker = createBottomSheetVisibilityTracker({ onClose: () => {}, backPress });
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
return { name, sheet };
|
||||
});
|
||||
|
||||
backPress.press();
|
||||
|
||||
expect(sheets.map((entry) => [entry.name, entry.sheet.events.at(-1)?.type])).toEqual([
|
||||
["below", "present"],
|
||||
["above", "dismiss"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops claiming Back once the sheet leaves the tree", () => {
|
||||
const context = setup();
|
||||
openSheet(context);
|
||||
context.tracker.attachController(null);
|
||||
|
||||
expect(context.backPress.press()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,22 @@ export interface BottomSheetVisibilityInput {
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The system Back press, as the tracker needs it.
|
||||
*
|
||||
* A sheet is presented through `@gorhom/portal`, which renders it into the app's own view tree
|
||||
* rather than a native modal window, so Android delivers Back to the navigator underneath it
|
||||
* unless something on top claims the press first.
|
||||
*
|
||||
* `subscribe` registers a listener and returns its removal. The listener returns `true` when it
|
||||
* claims the press. Listeners are offered the press newest-first, so a tracker holds its
|
||||
* subscription only while its sheet is on screen, and a stacked sheet answers before the one
|
||||
* below it.
|
||||
*/
|
||||
export interface BackPressSource {
|
||||
subscribe(onBackPress: () => boolean): () => void;
|
||||
}
|
||||
|
||||
export interface BottomSheetVisibilityTracker {
|
||||
attachController(controller: BottomSheetController | null): void;
|
||||
syncDesired(input: BottomSheetVisibilityInput): void;
|
||||
@@ -19,23 +35,46 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
||||
|
||||
export function createBottomSheetVisibilityTracker(opts: {
|
||||
onClose: () => void;
|
||||
backPress?: BackPressSource;
|
||||
}): BottomSheetVisibilityTracker {
|
||||
let controller: BottomSheetController | null = null;
|
||||
let visible = false;
|
||||
let isEnabled: boolean | undefined;
|
||||
let phase: BottomSheetPhase = "closed";
|
||||
let hasNotifiedClose = false;
|
||||
let releaseBackPress: (() => void) | null = null;
|
||||
|
||||
function setPhase(next: BottomSheetPhase): void {
|
||||
phase = next;
|
||||
syncBackPress();
|
||||
}
|
||||
|
||||
/** Holds the Back press for exactly the window the sheet is on screen, and no longer. */
|
||||
function syncBackPress(): void {
|
||||
const wantsBackPress = phase === "presented" && controller !== null;
|
||||
if (wantsBackPress === (releaseBackPress !== null)) return;
|
||||
if (!wantsBackPress) {
|
||||
releaseBackPress?.();
|
||||
releaseBackPress = null;
|
||||
return;
|
||||
}
|
||||
releaseBackPress =
|
||||
opts.backPress?.subscribe(() => {
|
||||
dismiss();
|
||||
return true;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
function present(): void {
|
||||
if (!controller || phase !== "closed") return;
|
||||
phase = "presenting";
|
||||
hasNotifiedClose = false;
|
||||
setPhase("presenting");
|
||||
controller.present();
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
if (!controller || phase === "closed" || phase === "dismissing") return;
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
controller.dismiss();
|
||||
}
|
||||
|
||||
@@ -48,6 +87,9 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
return {
|
||||
attachController(next) {
|
||||
controller = next;
|
||||
// A sheet that left the tree cannot answer, so it lets go of the press without changing
|
||||
// phase: the parent has not acknowledged the dismissal yet.
|
||||
syncBackPress();
|
||||
if (next && visible && isEnabled !== false) {
|
||||
present();
|
||||
}
|
||||
@@ -61,7 +103,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
return;
|
||||
}
|
||||
if (phase === "dismissing") {
|
||||
phase = "closed";
|
||||
setPhase("closed");
|
||||
hasNotifiedClose = false;
|
||||
return;
|
||||
}
|
||||
@@ -70,21 +112,21 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
handleSheetIndexChange(index) {
|
||||
if (index !== -1) {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
phase = "presented";
|
||||
setPhase("presented");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
}
|
||||
},
|
||||
handleSheetDismiss() {
|
||||
if (visible) {
|
||||
phase = "dismissing";
|
||||
setPhase("dismissing");
|
||||
notifyClose();
|
||||
return;
|
||||
}
|
||||
phase = "closed";
|
||||
setPhase("closed");
|
||||
hasNotifiedClose = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1788,7 +1788,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
onCreateAgentProfile={profileActions.create}
|
||||
onEditAgentProfile={profileActions.edit}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={handleSelectThinkingOption}
|
||||
features={agent.features}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EditorView } from "@codemirror/view";
|
||||
import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
@@ -41,12 +41,7 @@ export function FileFind({
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
model.open(editor.current);
|
||||
widget.current?.focus();
|
||||
|
||||
@@ -3,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: "بحث في اللوحة",
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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";
|
||||
}
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type SqliteValue,
|
||||
} from "@/runtime/replica-cache/row-store-sqlite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
DaemonClient,
|
||||
WorkspaceLabelListPayload,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeProjectDescriptor,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
||||
import { useWorkspaceLabels } from "@/workspace-labels";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import {
|
||||
DirectoryRefreshSupersededError,
|
||||
@@ -29,6 +33,8 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
supportsWorkspaceLabels = false;
|
||||
listWorkspaceLabelsCalls = 0;
|
||||
fetchAgentsCalls = 0;
|
||||
lastAgentOptions: unknown;
|
||||
fetchWorkspacesCalls = 0;
|
||||
@@ -144,19 +150,36 @@ class FakeDirectoryClient {
|
||||
});
|
||||
}
|
||||
|
||||
getLastServerInfoMessage(): null {
|
||||
return null;
|
||||
observeWorkspaceLabels() {
|
||||
return subscriptionFixture(this.listWorkspaceLabels(), () => () => {});
|
||||
}
|
||||
|
||||
async listWorkspaceLabels(): Promise<WorkspaceLabelListPayload> {
|
||||
this.listWorkspaceLabelsCalls += 1;
|
||||
return {
|
||||
requestId: "workspace-labels",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
|
||||
}
|
||||
}
|
||||
|
||||
const serverIds = new Set<string>();
|
||||
|
||||
function createDirectory(serverId: string): {
|
||||
function createDirectory(
|
||||
serverId: string,
|
||||
options?: { workspaceLabels?: boolean },
|
||||
): {
|
||||
client: FakeDirectoryClient;
|
||||
directory: DirectorySync;
|
||||
} {
|
||||
serverIds.add(serverId);
|
||||
const client = new FakeDirectoryClient();
|
||||
client.supportsWorkspaceLabels = options?.workspaceLabels === true;
|
||||
const directory = new DirectorySync(serverId, {
|
||||
onAgentStoppedRunning: () => undefined,
|
||||
markAgentLoading: () => undefined,
|
||||
@@ -207,6 +230,7 @@ function createAgent(serverId: string, id: string) {
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
useWorkspaceLabels.setState({ hosts: {} });
|
||||
});
|
||||
|
||||
describe("DirectorySync session readiness", () => {
|
||||
@@ -640,6 +664,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);
|
||||
@@ -1292,3 +1373,103 @@ it("fills every cached workspace beneath live updates received during the SQLite
|
||||
await cache.flush();
|
||||
database.close();
|
||||
});
|
||||
|
||||
function createWorkspaceLabelDirectory(serverId: string) {
|
||||
const { client, directory } = createDirectory(serverId, { workspaceLabels: true });
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: {
|
||||
directorySync: true,
|
||||
projectList: true,
|
||||
workspaceLabels: true,
|
||||
workspaceMultiplicity: true,
|
||||
},
|
||||
});
|
||||
return { client, directory };
|
||||
}
|
||||
|
||||
async function flushWorkspaceLabels(): Promise<void> {
|
||||
for (let index = 0; index < 4; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("DirectorySync workspace labels", () => {
|
||||
it("loads the catalog when an agent route is open before the sidebar", async () => {
|
||||
const serverId = "workspace-labels-route-then-full";
|
||||
const { directory } = createWorkspaceLabelDirectory(serverId);
|
||||
|
||||
directory.setAgentRouteDemand(["agent-1"]);
|
||||
await directory.refreshDemand();
|
||||
directory.setDemand({}, true);
|
||||
await flushWorkspaceLabels();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("loads the catalog when the sidebar demands the directory first", async () => {
|
||||
const serverId = "workspace-labels-full-first";
|
||||
const { directory } = createWorkspaceLabelDirectory(serverId);
|
||||
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the picker online during a full refresh", async () => {
|
||||
const serverId = "workspace-labels-refresh-no-flicker";
|
||||
const { client, directory } = createWorkspaceLabelDirectory(serverId);
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
const callsBeforeRefresh = client.listWorkspaceLabelsCalls;
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = useWorkspaceLabels.subscribe((state) => {
|
||||
const status = state.hosts[serverId]?.status;
|
||||
if (status) statuses.push(status);
|
||||
});
|
||||
|
||||
await directory.refreshDemand();
|
||||
|
||||
expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||
expect(statuses).not.toContain("offline");
|
||||
unsubscribe();
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("restores the catalog after reconnecting", async () => {
|
||||
const serverId = "workspace-labels-reconnect";
|
||||
const { client, directory } = createWorkspaceLabelDirectory(serverId);
|
||||
directory.setDemand({}, true);
|
||||
await directory.refreshDemand();
|
||||
|
||||
directory.connectionChanged({
|
||||
client: null,
|
||||
status: "offline",
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
});
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline");
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
await flushWorkspaceLabels();
|
||||
|
||||
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
|
||||
status: "online",
|
||||
labels: [{ name: "Urgent", color: "red" }],
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +189,8 @@ export class DirectorySync {
|
||||
this.connection = connection;
|
||||
this.abortPendingSessionWaits();
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
|
||||
void this.connectWorkspaceLabels().catch(() => undefined);
|
||||
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migrateSidebarOrderState } from "./sidebar-order-store";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeSidebarOrderUpdates,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/sidebar-workspaces-view-model";
|
||||
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
|
||||
|
||||
describe("migrateSidebarOrderState", () => {
|
||||
it("prefixes legacy per-server workspace order with the source server id", () => {
|
||||
@@ -31,3 +35,71 @@ describe("migrateSidebarOrderState", () => {
|
||||
expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]);
|
||||
});
|
||||
});
|
||||
|
||||
// A directory whose name ends in a space produces a view key that ends in a
|
||||
// space. Trimming it on write used to make the sidebar's reconcile effect loop
|
||||
// forever, crashing the app with React error #185 (see #4880).
|
||||
describe("sidebar order keys that end in whitespace", () => {
|
||||
const PROJECT_KEY = "host:srv-1:/home/u/Reklamation ";
|
||||
|
||||
beforeEach(() => {
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a project key exactly as given", () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder([PROJECT_KEY]);
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]);
|
||||
});
|
||||
|
||||
it("scopes a workspace order under the project key the sidebar indexes by", () => {
|
||||
useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]);
|
||||
|
||||
// use-sidebar-workspaces-list reads workspaceOrderByProject by view key
|
||||
// directly, so the scope has to be stored under that same key.
|
||||
expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([
|
||||
"srv-1:main",
|
||||
]);
|
||||
expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]);
|
||||
});
|
||||
|
||||
it("settles the sidebar order reconcile instead of rewriting it forever", () => {
|
||||
const projects: SidebarProjectEntry[] = [
|
||||
{
|
||||
viewKey: PROJECT_KEY,
|
||||
projectName: "Reklamation ",
|
||||
projectKind: "non_git",
|
||||
iconWorkingDir: "/home/u/Reklamation ",
|
||||
hosts: [],
|
||||
workspaces: [],
|
||||
},
|
||||
];
|
||||
const runPass = () =>
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: useSidebarOrderStore.getState().projectOrder,
|
||||
getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key),
|
||||
});
|
||||
|
||||
// First pass adds the newly visible project.
|
||||
const first = runPass();
|
||||
expect(first.projectOrder).toEqual([PROJECT_KEY]);
|
||||
useSidebarOrderStore.getState().setProjectOrder(first.projectOrder ?? []);
|
||||
|
||||
// Second pass must find nothing left to do; otherwise the effect that
|
||||
// applies these updates re-runs on every render.
|
||||
expect(runPass().projectOrder).toBeNull();
|
||||
});
|
||||
|
||||
it("still trims keys when migrating persisted state", () => {
|
||||
const migrated = migrateSidebarOrderState({
|
||||
projectOrder: [" host-a:one ", "host-a:one"],
|
||||
});
|
||||
|
||||
expect(migrated.projectOrder).toEqual(["host-a:one"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ interface SidebarWorkspaceOrderScope {
|
||||
projectViewKey: string;
|
||||
}
|
||||
|
||||
// Trims each key. Only for persisted state read at migration time, where a
|
||||
// stray space is an artifact of an older format rather than part of the key.
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
@@ -54,6 +56,28 @@ function normalizeKeys(keys: string[]): string[] {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops blank keys and duplicates but keeps each key exactly as given. View
|
||||
* keys embed a project's path, so a directory whose name ends in a space
|
||||
* produces a key that ends in a space. Trimming it stores a key that can never
|
||||
* match the one the sidebar looks up, so the caller sees its key as missing,
|
||||
* writes it again, and the effect that reconciles the order never settles.
|
||||
*/
|
||||
function dedupeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!key.trim() || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(key);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceOrderByProject(
|
||||
workspaceOrderByProject: Record<string, string[]> | undefined,
|
||||
): Record<string, string[]> {
|
||||
@@ -134,27 +158,22 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
workspaceOrderByProject: {},
|
||||
getProjectOrder: () => get().projectOrder,
|
||||
setProjectOrder: (keys) => {
|
||||
const normalized = normalizeKeys(keys);
|
||||
set({ projectOrder: normalized });
|
||||
set({ projectOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder,
|
||||
setPinnedWorkspaceOrder: (keys) => {
|
||||
const normalized = normalizeKeys(keys);
|
||||
set({ pinnedWorkspaceOrder: normalized });
|
||||
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
|
||||
},
|
||||
getWorkspaceOrder: (projectViewKey) => {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) return [];
|
||||
return get().workspaceOrderByProject[scope] ?? [];
|
||||
if (!projectViewKey.trim()) return [];
|
||||
return get().workspaceOrderByProject[projectViewKey] ?? [];
|
||||
},
|
||||
setWorkspaceOrder: (projectViewKey, keys) => {
|
||||
const scope = projectViewKey.trim();
|
||||
if (!scope) return;
|
||||
const normalized = normalizeKeys(keys);
|
||||
if (!projectViewKey.trim()) return;
|
||||
set((state) => ({
|
||||
workspaceOrderByProject: {
|
||||
...state.workspaceOrderByProject,
|
||||
[scope]: normalized,
|
||||
[projectViewKey]: dedupeKeys(keys),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import {
|
||||
createSidebarViewStorage,
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
useSidebarViewStore,
|
||||
} from "./sidebar-view-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MemoryStorage extends StateStorage<Promise<void>> {
|
||||
reads: string[];
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
|
||||
readCursor: () => undefined,
|
||||
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
onCatchUpEnded: () => undefined,
|
||||
reportError: () => undefined,
|
||||
schedule: () => () => undefined,
|
||||
});
|
||||
|
||||
@@ -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
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
createTimelineReplica,
|
||||
@@ -443,6 +444,119 @@ describe("viewed timeline persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("create handoff lifetime", () => {
|
||||
const DRAFT_ID = "draft-1";
|
||||
|
||||
afterEach(() => useCreateFlowStore.getState().clearAll());
|
||||
|
||||
function sentCreateHandoff(): void {
|
||||
useCreateFlowStore.getState().setPending({
|
||||
draftId: DRAFT_ID,
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
clientMessageId: "client-message-1",
|
||||
text: "Create this chat.",
|
||||
timestamp: 0,
|
||||
});
|
||||
useCreateFlowStore.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
|
||||
}
|
||||
|
||||
function createHandoff(): PendingCreateAttempt | undefined {
|
||||
return useCreateFlowStore.getState().pendingByDraftId[DRAFT_ID];
|
||||
}
|
||||
|
||||
function syncedTailPage(): Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] {
|
||||
return {
|
||||
requestId: "tail-1",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface HeldCatchUpOwner {
|
||||
owner: ViewedTimelineOwner;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
async function ownerWithHeldCatchUp(): Promise<HeldCatchUpOwner> {
|
||||
let releaseTailFetch!: () => void;
|
||||
let tailFetchStarted = false;
|
||||
const tailFetched = new Promise<void>((resolve) => {
|
||||
releaseTailFetch = resolve;
|
||||
});
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => undefined, commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
const owner = createViewedTimelineOwner({
|
||||
serverId: SERVER_ID,
|
||||
replica,
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
drainQueuedAgentMessage: () => undefined,
|
||||
ports: {
|
||||
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
|
||||
readCursor: () => undefined,
|
||||
fetchPage: async () => {
|
||||
tailFetchStarted = true;
|
||||
await tailFetched;
|
||||
return { hasNewer: false, endCursor: null };
|
||||
},
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
reportError: () => undefined,
|
||||
schedule: () => () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
owner.setConnected(true);
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect.poll(() => tailFetchStarted).toBe(true);
|
||||
return { owner, release: releaseTailFetch };
|
||||
}
|
||||
|
||||
it("keeps the handoff through the first synchronized page and releases it when the chat is current", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
sentCreateHandoff();
|
||||
const { owner, release } = await ownerWithHeldCatchUp();
|
||||
|
||||
owner.applyTimelineResponse(syncedTailPage());
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
|
||||
).toMatchObject({ status: "synced" });
|
||||
expect(createHandoff()).toMatchObject({ agentId: AGENT_ID, lifecycle: "sent" });
|
||||
|
||||
release();
|
||||
await expect.poll(() => createHandoff()).toBeUndefined();
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("releases the handoff when the chat's tab closes before catch-up completes", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
sentCreateHandoff();
|
||||
const { owner, release } = await ownerWithHeldCatchUp();
|
||||
|
||||
owner.replaceVisibleAgentIds("test", []);
|
||||
owner.replaceOpenTabAgentIds([]);
|
||||
|
||||
expect(createHandoff()).toBeUndefined();
|
||||
release();
|
||||
owner.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function createSqliteCache() {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
let beforeRead = async () => {};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
consumeForcedTimelineTailReplacement,
|
||||
createViewedTimelineSync,
|
||||
type TimelineResponsePayload,
|
||||
type ViewedTimelineStatus,
|
||||
type ViewedTimelineSyncPorts,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
@@ -31,6 +32,11 @@ interface MembershipRequest {
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
interface TimelineCurrentReport {
|
||||
agentId: string;
|
||||
status: ViewedTimelineStatus;
|
||||
}
|
||||
|
||||
interface TimelineFetch {
|
||||
agentId: string;
|
||||
request: ProjectedTimelineForwardFetchPlan;
|
||||
@@ -126,9 +132,16 @@ class TimelineWorld {
|
||||
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
|
||||
readonly cacheRequests: string[] = [];
|
||||
readonly forcedTimelineTailReplacements = new Set<string>();
|
||||
readonly reportedCurrent: TimelineCurrentReport[] = [];
|
||||
cacheGate: Deferred<void> | null = null;
|
||||
readonly sync = createViewedTimelineSync({
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
onCatchUpEnded: (agentId) => {
|
||||
this.reportedCurrent.push({
|
||||
agentId,
|
||||
status: this.sync.getAgentTimelineStatus(agentId),
|
||||
});
|
||||
},
|
||||
prepare: async (agentId) => {
|
||||
this.cacheRequests.push(agentId);
|
||||
this.cacheRequestWaiters.shift()?.(agentId);
|
||||
@@ -436,6 +449,60 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
});
|
||||
|
||||
test("reports a chat current only once its parked follow-up page completes", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
const tail = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
|
||||
tail.respond({ hasNewer: false });
|
||||
|
||||
const recovery = await world.nextFetch("agent-a");
|
||||
expect(world.reportedCurrent).toEqual([]);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
|
||||
recovery.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
|
||||
const probe = await world.nextFetch("agent-a");
|
||||
probe.respond({ hasNewer: true, seq: 82 });
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(world.reportedCurrent).toEqual([]);
|
||||
|
||||
fallback.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("stops owing a chat a catch-up when its tab closes mid-fetch", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("pane", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
await world.nextFetch("agent-a");
|
||||
expect(world.reportedCurrent).toEqual([]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("pane", []);
|
||||
world.sync.replaceOpenTabAgentIds([]);
|
||||
|
||||
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "pending" }]);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
|
||||
@@ -247,9 +247,6 @@ function finalizeProcessedTimeline(input: {
|
||||
clearAgentInitializingFlag(input.serverId, input.agentId);
|
||||
}
|
||||
if (input.synchronized) {
|
||||
useCreateFlowStore
|
||||
.getState()
|
||||
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
@@ -317,6 +314,11 @@ export interface ViewedTimelineSyncPorts {
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<TimelinePageResult>;
|
||||
fetchLatestTail(agentId: string): Promise<TimelinePageResult>;
|
||||
/**
|
||||
* The sync no longer owes this chat a catch-up: it reached current, or it left the
|
||||
* demanded set. Disconnect and backgrounding keep the obligation and do not report here.
|
||||
*/
|
||||
onCatchUpEnded(agentId: string): void;
|
||||
reportError(error: unknown): void;
|
||||
schedule(task: () => void, delayMs: number): () => void;
|
||||
}
|
||||
@@ -346,7 +348,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
|
||||
export type ViewedTimelineOwnerPorts = Omit<
|
||||
ViewedTimelineSyncPorts,
|
||||
"prepare" | "replaceDemandedAgentIds"
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
|
||||
>;
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
@@ -376,6 +378,8 @@ export function createViewedTimelineOwner(input: {
|
||||
prepare: (agentId) => input.replica.prepare(agentId),
|
||||
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
|
||||
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
|
||||
onCatchUpEnded: (agentId) =>
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
|
||||
});
|
||||
const streamQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId: input.serverId,
|
||||
@@ -530,6 +534,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const wasPending = visibilityCatchUpPending.delete(agentId);
|
||||
const hadError = visibilityCatchUpErrors.delete(agentId);
|
||||
const wasRetrying = manualRetries.delete(agentId);
|
||||
ports.onCatchUpEnded(agentId);
|
||||
if (wasPending || hadError || wasRetrying) notifyListeners();
|
||||
};
|
||||
|
||||
@@ -785,12 +790,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
return;
|
||||
}
|
||||
|
||||
const released: string[] = [];
|
||||
for (const agentId of desired) {
|
||||
if (!nextDesired.includes(agentId)) {
|
||||
cancelCatchUp(agentId);
|
||||
visibilityCatchUpPending.delete(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
released.push(agentId);
|
||||
}
|
||||
}
|
||||
for (const agentId of nextDesired) {
|
||||
@@ -806,6 +813,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
desired = nextDesired;
|
||||
ports.replaceDemandedAgentIds(desired);
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of released) ports.onCatchUpEnded(agentId);
|
||||
notifyListeners();
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
@@ -75,6 +75,49 @@ describe("splitMarkdownBlocks", () => {
|
||||
).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]);
|
||||
});
|
||||
|
||||
it("keeps a link reference definition with the paragraph that uses it", () => {
|
||||
expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([
|
||||
"See the [docs][d].\n\n[d]: https://example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds a leading definition-only block into the block below it", () => {
|
||||
expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([
|
||||
'[d]: https://example.com "Docs"\n\nSee the [docs][d].',
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds several definition lines and several definition blocks into one block", () => {
|
||||
expect(
|
||||
splitMarkdownBlocks(
|
||||
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'\n\nAfter",
|
||||
),
|
||||
).toEqual([
|
||||
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes every destination the renderer accepts, including escaped spaces", () => {
|
||||
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([
|
||||
"See [docs].\n\n[docs]: docs\\ folder/readme",
|
||||
]);
|
||||
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: <docs folder/readme> 'Title'")).toEqual([
|
||||
"See [docs].\n\n[docs]: <docs folder/readme> 'Title'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a definition-only message as its own block", () => {
|
||||
expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]);
|
||||
});
|
||||
|
||||
it("does not fold a paragraph that merely starts with a bracketed link", () => {
|
||||
expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([
|
||||
"Intro",
|
||||
"[Link](https://example.com) and more prose",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats triple newlines as a split point and filters empty blocks", () => {
|
||||
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
|
||||
"First paragraph",
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -147,6 +147,11 @@ class WorkspaceLabelsController {
|
||||
client: DaemonClient;
|
||||
supportsWorkspaceLabels: boolean;
|
||||
}): Promise<void> {
|
||||
const existing = this.connections.get(input.serverId);
|
||||
if (existing?.client === input.client && input.supportsWorkspaceLabels) {
|
||||
await this.refresh(input.serverId);
|
||||
return;
|
||||
}
|
||||
this.disconnect(input.serverId);
|
||||
const replica = this.replicas.get(input.serverId) ?? new HostWorkspaceLabelReplica();
|
||||
this.replicas.set(input.serverId, replica);
|
||||
|
||||
@@ -131,6 +131,7 @@ function WorkspaceLabelPickerPage({
|
||||
}, [assignedLabels, host?.status, labels, model]);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot);
|
||||
const offline = !snapshot.online;
|
||||
const error = snapshot.error ?? (host?.error ? t("workspaceLabels.errors.load") : null);
|
||||
const pending = useMemo(() => new Set(snapshot.pendingNames), [snapshot.pendingNames]);
|
||||
const toggle = useCallback(
|
||||
(label: WorkspaceLabelDefinition, assigned: boolean) => {
|
||||
@@ -159,9 +160,7 @@ function WorkspaceLabelPickerPage({
|
||||
>
|
||||
{t("workspaceLabels.create")}
|
||||
</MenuSubTrigger>
|
||||
{snapshot.error ? (
|
||||
<MenuHint testID="workspace-label-picker-error">{snapshot.error}</MenuHint>
|
||||
) : null}
|
||||
{error ? <MenuHint testID="workspace-label-picker-error">{error}</MenuHint> : null}
|
||||
{host?.status === "unsupported" ? (
|
||||
<MenuHint>{t("workspaceLabels.updateHostUse")}</MenuHint>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* AsyncStorage has no implementation under Node: its default export reaches for
|
||||
* the native module and every call rejects, so any test that touches a
|
||||
* persisted store emits unhandled rejections from the persist middleware.
|
||||
*
|
||||
* This is a real in-memory key-value store with the same contract, not a
|
||||
* recording double, so a test can read back what a store wrote.
|
||||
*/
|
||||
const entries = new Map<string, string>();
|
||||
|
||||
export function clearAsyncStorageStub(): void {
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
const AsyncStorageStub = {
|
||||
getItem: (key: string): Promise<string | null> => Promise.resolve(entries.get(key) ?? null),
|
||||
setItem: (key: string, value: string): Promise<void> => {
|
||||
entries.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeItem: (key: string): Promise<void> => {
|
||||
entries.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
getAllKeys: (): Promise<string[]> => Promise.resolve([...entries.keys()]),
|
||||
// `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
|
||||
// hands it the real AsyncStorage, so a stub that stops at the single-key calls
|
||||
// fails any test that reaches the provider cache with `multiGet is not a function`.
|
||||
multiGet: (keys: readonly string[]): Promise<Array<[string, string | null]>> =>
|
||||
Promise.resolve(keys.map((key) => [key, entries.get(key) ?? null] as [string, string | null])),
|
||||
multiSet: (pairs: ReadonlyArray<readonly [string, string]>): Promise<void> => {
|
||||
for (const [key, value] of pairs) entries.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
multiRemove: (keys: readonly string[]): Promise<void> => {
|
||||
for (const key of keys) entries.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clear: (): Promise<void> => {
|
||||
entries.clear();
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
export default AsyncStorageStub;
|
||||
@@ -185,6 +185,12 @@ export default defineConfig({
|
||||
find: /^expo-linking$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),
|
||||
},
|
||||
// No Node implementation: every call on the real module rejects, so a test
|
||||
// touching a persisted store drowns in unhandled rejections.
|
||||
{
|
||||
find: /^@react-native-async-storage\/async-storage$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"),
|
||||
},
|
||||
{
|
||||
find: /^lucide-react-native$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/server": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -25,4 +25,11 @@ describe("timelineItemIdentity", () => {
|
||||
).toBe("review/row-1");
|
||||
expect(timelineItemIdentity({ type: "reasoning", text: "hi" })).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies an assistant message by its provider message id", () => {
|
||||
expect(
|
||||
timelineItemIdentity({ type: "assistant_message", text: "hi", messageId: "msg-1" }),
|
||||
).toBe("message:msg-1");
|
||||
expect(timelineItemIdentity({ type: "assistant_message", text: "hi" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user