mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b56cbfe8 | ||
|
|
c67b7158b4 | ||
|
|
91dc927335 | ||
|
|
49f9cec6be | ||
|
|
e07da55f81 | ||
|
|
27d6a71856 | ||
|
|
bbf8cce3f7 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd | ||
|
|
f4efdbded7 | ||
|
|
c976e2e5a5 | ||
|
|
b2bb512b31 | ||
|
|
067937bace | ||
|
|
d615d3d424 | ||
|
|
fbe5005aa1 | ||
|
|
e998e0a082 | ||
|
|
ae42b0afb0 | ||
|
|
829cc5e173 | ||
|
|
48384cafdf | ||
|
|
2c7b38bc7e | ||
|
|
222d45a2fc | ||
|
|
373da70694 | ||
|
|
c3e1e084a0 | ||
|
|
9978988e35 | ||
|
|
8768500fc0 | ||
|
|
89073d4c5c | ||
|
|
ec43e90670 | ||
|
|
897a0abb1a | ||
|
|
c4771ca467 | ||
|
|
05bfffad41 | ||
|
|
faee1cd952 | ||
|
|
ba4595d4e8 | ||
|
|
34c9fd03c9 | ||
|
|
9a3f3a0dc6 | ||
|
|
d6861f81e5 | ||
|
|
3054f80050 | ||
|
|
6d37f7fd9f | ||
|
|
94ab368e4e | ||
|
|
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 |
@@ -180,6 +180,37 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run file observation tests
|
||||
run: |
|
||||
npm run test:unit --workspace=@getpaseo/server -- \
|
||||
src/server/file-observer \
|
||||
src/server/workspace-git-service.observation.test.ts \
|
||||
src/server/workspace-git-service.observation.integration.test.ts \
|
||||
--bail=1
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
|
||||
@@ -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
|
||||
|
||||
+71
-5
@@ -1,14 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0-beta.2 - 2026-09-18
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
|
||||
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
|
||||
|
||||
### 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 daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
|
||||
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
|
||||
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
|
||||
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
|
||||
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
|
||||
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
|
||||
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
|
||||
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
|
||||
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
|
||||
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
|
||||
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
|
||||
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
|
||||
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
|
||||
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
|
||||
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
|
||||
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
|
||||
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
|
||||
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
|
||||
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
|
||||
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
|
||||
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
|
||||
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
|
||||
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
|
||||
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
|
||||
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
|
||||
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
|
||||
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
|
||||
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
|
||||
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
|
||||
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
|
||||
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
|
||||
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
|
||||
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
|
||||
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
|
||||
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
|
||||
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
|
||||
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
|
||||
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
|
||||
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
|
||||
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
|
||||
|
||||
## 0.9.0-beta.1 - 2026-09-17
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -80,6 +144,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) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- 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)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- 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). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- 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) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- 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.
|
||||
|
||||
@@ -714,7 +714,7 @@ Each entry in the `models` array:
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
+5
-2
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
|
||||
## Session grants and agent operations
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
|
||||
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
|
||||
workspace through `workspace.title.set.request` without `workspace.manage`.
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+14
-5
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
|
||||
top-level provider options.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
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.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"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.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37528,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@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.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@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.2",
|
||||
"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.2",
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
|
||||
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
|
||||
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
|
||||
|
||||
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
([key, bindingId, value]) => {
|
||||
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
|
||||
},
|
||||
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-step shortcut", () => {
|
||||
test("completes a chord whose second step is a bare key", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
|
||||
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
|
||||
test("completes a chord whose second step carries a modifier", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("Control+j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
// "Toggle command center" recorded as a two-key sequence. Both platform
|
||||
// bindings are overridden so the spec does not depend on the host OS.
|
||||
const COMMAND_CENTER_SEQUENCE = {
|
||||
"command-center-toggle-cmd-k-mac": "Cmd+K J",
|
||||
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
|
||||
};
|
||||
|
||||
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
|
||||
const SEQUENCE_FINAL_KEY = "j";
|
||||
|
||||
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
|
||||
async function recordCommandCenterSequence(page: Page): Promise<void> {
|
||||
await page.addInitScript((overrides) => {
|
||||
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
|
||||
}, COMMAND_CENTER_SEQUENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the window and let the app render again, so the resize lands between
|
||||
* the two keys of the sequence rather than after them.
|
||||
*/
|
||||
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.waitForFunction((expected) => window.innerWidth === expected, width);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-key shortcut sequences", () => {
|
||||
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
test("a sequence completes when the window resizes between its keys", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await resizeWindowAndSettle(page, 1100);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"resourceClass": "large"
|
||||
"resourceClass": "large",
|
||||
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.2",
|
||||
"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();
|
||||
|
||||
@@ -54,6 +54,54 @@ describe("question form card core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps checked options and appends the other answer for multi-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
|
||||
Fruits: "Apple, Cherry, durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
|
||||
Fruits: "Apple, Cherry",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
|
||||
Fruits: "durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
|
||||
});
|
||||
|
||||
test("replaces the selected option with the other answer for single-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
|
||||
Provider: "OpenCode",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
|
||||
Provider: "Codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for explicit other questions", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
|
||||
@@ -111,10 +111,13 @@ export function buildQuestionFormAnswers(
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
// Multi-select keeps the checked options and appends the custom answer, the way
|
||||
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
|
||||
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
|
||||
continue;
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
@@ -123,8 +126,7 @@ export function buildQuestionFormAnswers(
|
||||
}
|
||||
}
|
||||
|
||||
if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
if (labels.length > 0) {
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
|
||||
* gap between that input and React state: the input owns its text and never replays state, so a
|
||||
* card that drops the Other text from state alone keeps showing it while submit ignores it.
|
||||
*/
|
||||
|
||||
interface Mounted {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
||||
return {
|
||||
key: "perm-1",
|
||||
agentId: "agent-1",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: { questions: [question] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountCard(question: Record<string, unknown>) {
|
||||
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<QuestionFormCard
|
||||
permission={buildPermission(question)}
|
||||
onRespond={onRespond}
|
||||
isResponding={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
|
||||
const view = within(container);
|
||||
const optionRole = question.multiSelect ? "checkbox" : "radio";
|
||||
const otherInput = () =>
|
||||
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
|
||||
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
|
||||
const type = (text: string) => {
|
||||
const input = otherInput();
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
|
||||
act(() => {
|
||||
valueSetter.call(input, text);
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
||||
});
|
||||
};
|
||||
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
|
||||
const submittedAnswers = (): Record<string, string> => {
|
||||
const response = onRespond.mock.calls[0]?.[0];
|
||||
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
|
||||
return (response.updatedInput as { answers: Record<string, string> }).answers;
|
||||
};
|
||||
return { check, type, otherInput, submit, submittedAnswers };
|
||||
}
|
||||
|
||||
const multiSelectQuestion = {
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
const singleSelectQuestion = {
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
describe("QuestionFormCard other answers", () => {
|
||||
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.check("Apple");
|
||||
card.check("Cherry");
|
||||
card.type("durian");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
|
||||
});
|
||||
|
||||
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.type("durian");
|
||||
card.check("Apple");
|
||||
card.check("Banana");
|
||||
|
||||
expect(card.otherInput().value).toBe("durian");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
|
||||
});
|
||||
|
||||
it("replaces the selected option with the typed other answer (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.check("Codex");
|
||||
card.type("OpenCode");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
|
||||
});
|
||||
|
||||
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.type("OpenCode");
|
||||
card.check("Codex");
|
||||
|
||||
expect(card.otherInput().value).toBe("");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -9,6 +9,7 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -258,6 +259,7 @@ function QuestionNav({
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
inputRef: RefObject<EditingTextInputHandle | null>;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
inputRef,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
@@ -303,6 +306,7 @@ function QuestionOtherInput({
|
||||
);
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
// Single-select: an option and a custom answer replace each other, as in Claude Code.
|
||||
// Multi-select keeps both. The editing surface owns its text and never replays state
|
||||
// (docs/forms.md), so clearing state alone would leave stale text on screen that
|
||||
// submit ignores; clear the surface explicitly.
|
||||
if (!multiSelect && otherTexts[qIndex]) {
|
||||
setOtherTexts((prev) => {
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
otherInputRef.current?.replaceText("");
|
||||
}
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
[activeQuestionIndex, otherTexts, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
if (text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const setOtherText = useCallback(
|
||||
(qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
|
||||
if (!multiSelect && text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
},
|
||||
[questions],
|
||||
);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
@@ -554,6 +570,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
inputRef={otherInputRef}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/keyboard/route-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
@@ -75,7 +76,6 @@ export function useKeyboardShortcuts({
|
||||
const openProjectPickerAction = useOpenAddProject();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const badgeModifierKeyRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
@@ -107,271 +107,285 @@ export function useKeyboardShortcuts({
|
||||
publishBrowserShortcutPolicy();
|
||||
}, [isDesktopApp, publishBrowserShortcutPolicy]);
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, isDesktop: isDesktopApp },
|
||||
bindings,
|
||||
);
|
||||
|
||||
// The keyup listener matches the released key against the current modifier,
|
||||
// so a modifier held while the jump binding changes could never be released
|
||||
// and the badges would stay up until a blur. Clear them when it changes.
|
||||
useEffect(() => {
|
||||
resetModifiers();
|
||||
}, [badgeModifierKey, resetModifiers]);
|
||||
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (isDesktopApp) {
|
||||
state.setCmdOrCtrlDown(down);
|
||||
} else {
|
||||
state.setAltDown(down);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
};
|
||||
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const performShortcutAction = (
|
||||
action: ShortcutAction,
|
||||
event: KeyboardEvent | null,
|
||||
browserFocusRestoreElement: HTMLElement | null = null,
|
||||
): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
if (navigateToLastWorkspace()) {
|
||||
return true;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return true;
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
if (event) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
} else {
|
||||
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
|
||||
}
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const routeAndPerformShortcut = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: input.action, payload: input.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
const handled = performShortcutAction(
|
||||
shortcutAction,
|
||||
input.domEvent,
|
||||
input.browserFocusRestoreElement,
|
||||
);
|
||||
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
||||
exitFocusMode();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
const resolveAndPerformShortcut = (input: {
|
||||
event: KeyboardShortcutInput;
|
||||
focusScope: KeyboardFocusScope;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}) => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const previousChordState = chordStateRef.current;
|
||||
const result = resolveKeyboardShortcut({
|
||||
event: input.event,
|
||||
context: {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
focusScope: input.focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
publishBrowserShortcutPolicy();
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
chordStateRef.current = result.nextChordState;
|
||||
if (
|
||||
shouldPublishBrowserShortcutPolicy({
|
||||
isBrowserInput: "browserId" in input.event,
|
||||
previousChordState,
|
||||
nextChordState: result.nextChordState,
|
||||
})
|
||||
) {
|
||||
publishBrowserShortcutPolicy(result.nextChordState);
|
||||
}
|
||||
|
||||
if (result.preventDefault && input.domEvent) {
|
||||
input.domEvent.preventDefault();
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = routeAndPerformShortcut({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
domEvent: input.domEvent,
|
||||
browserFocusRestoreElement: input.browserFocusRestoreElement,
|
||||
});
|
||||
if (!handled || !input.domEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.match.preventDefault) {
|
||||
input.domEvent.preventDefault();
|
||||
}
|
||||
if (result.match.stopPropagation) {
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// The window listeners must outlive ordinary re-renders: removing them resets
|
||||
// any in-progress chord. Stable events let them read the latest render instead
|
||||
// of being re-registered whenever a route, callback, or selection changes.
|
||||
const handleKeyDown = useStableEvent((event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchTopWebOverlayKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// During IME composition, Enter confirms the candidate selection and must
|
||||
// not route through global shortcuts like message send.
|
||||
if (isImeComposingKeyboardEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (
|
||||
key === "Escape" &&
|
||||
pathname.startsWith("/settings") &&
|
||||
!isMobile &&
|
||||
hasActiveWebOverlay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (key === badgeModifierKey && !event.shiftKey) {
|
||||
setBadgeModifierDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
resolveAndPerformShortcut({
|
||||
event,
|
||||
focusScope,
|
||||
domEvent: event,
|
||||
});
|
||||
});
|
||||
|
||||
const handleKeyUp = useStableEvent((event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === badgeModifierKey) {
|
||||
setBadgeModifierDown(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBrowserShortcutInput = useStableEvent((payload: unknown) => {
|
||||
const input = parseBrowserShortcutInput(payload);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
resolveAndPerformShortcut({
|
||||
event: input,
|
||||
focusScope: "browser",
|
||||
domEvent: null,
|
||||
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!shortcutsAvailable) return;
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, isDesktop: isDesktopApp },
|
||||
bindings,
|
||||
);
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (isDesktopApp) {
|
||||
state.setCmdOrCtrlDown(down);
|
||||
} else {
|
||||
state.setAltDown(down);
|
||||
}
|
||||
};
|
||||
|
||||
// The keyup listener matches the released key against the modifier derived
|
||||
// when the effect last ran, so a modifier held while the jump binding
|
||||
// changes can never be released -- the badges would stay up until a blur.
|
||||
// Clear on change only: this effect also re-runs on every navigation, and
|
||||
// clearing unconditionally would drop the badges mid Cmd+1, Cmd+2.
|
||||
if (badgeModifierKeyRef.current !== badgeModifierKey) {
|
||||
badgeModifierKeyRef.current = badgeModifierKey;
|
||||
resetModifiers();
|
||||
}
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
};
|
||||
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const performShortcutAction = (
|
||||
action: ShortcutAction,
|
||||
event: KeyboardEvent | null,
|
||||
browserFocusRestoreElement: HTMLElement | null = null,
|
||||
): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
if (navigateToLastWorkspace()) {
|
||||
return true;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return true;
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
if (event) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
} else {
|
||||
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
|
||||
}
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const routeAndPerformShortcut = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: input.action, payload: input.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
const handled = performShortcutAction(
|
||||
shortcutAction,
|
||||
input.domEvent,
|
||||
input.browserFocusRestoreElement,
|
||||
);
|
||||
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
||||
exitFocusMode();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
const resolveAndPerformShortcut = (input: {
|
||||
event: KeyboardShortcutInput;
|
||||
focusScope: KeyboardFocusScope;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}) => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const previousChordState = chordStateRef.current;
|
||||
const result = resolveKeyboardShortcut({
|
||||
event: input.event,
|
||||
context: {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
focusScope: input.focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
publishBrowserShortcutPolicy();
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
chordStateRef.current = result.nextChordState;
|
||||
if (
|
||||
shouldPublishBrowserShortcutPolicy({
|
||||
isBrowserInput: "browserId" in input.event,
|
||||
previousChordState,
|
||||
nextChordState: result.nextChordState,
|
||||
})
|
||||
) {
|
||||
publishBrowserShortcutPolicy(result.nextChordState);
|
||||
}
|
||||
|
||||
if (result.preventDefault && input.domEvent) {
|
||||
input.domEvent.preventDefault();
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = routeAndPerformShortcut({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
domEvent: input.domEvent,
|
||||
browserFocusRestoreElement: input.browserFocusRestoreElement,
|
||||
});
|
||||
if (!handled || !input.domEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.match.preventDefault) {
|
||||
input.domEvent.preventDefault();
|
||||
}
|
||||
if (result.match.stopPropagation) {
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchTopWebOverlayKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// During IME composition, Enter confirms the candidate selection and must
|
||||
// not route through global shortcuts like message send.
|
||||
if (isImeComposingKeyboardEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (
|
||||
key === "Escape" &&
|
||||
pathname.startsWith("/settings") &&
|
||||
!isMobile &&
|
||||
hasActiveWebOverlay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (key === badgeModifierKey && !event.shiftKey) {
|
||||
setBadgeModifierDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
resolveAndPerformShortcut({
|
||||
event,
|
||||
focusScope,
|
||||
domEvent: event,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === badgeModifierKey) {
|
||||
setBadgeModifierDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
@@ -382,18 +396,7 @@ export function useKeyboardShortcuts({
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
|
||||
const browserShortcutSubscription = isElectronRuntime()
|
||||
? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => {
|
||||
const input = parseBrowserShortcutInput(payload);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
resolveAndPerformShortcut({
|
||||
event: input,
|
||||
focusScope: "browser",
|
||||
domEvent: null,
|
||||
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
|
||||
});
|
||||
})
|
||||
? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput)
|
||||
: null;
|
||||
return () => {
|
||||
if (chordStateRef.current.timeoutId !== null) {
|
||||
@@ -415,23 +418,11 @@ export function useKeyboardShortcuts({
|
||||
}
|
||||
};
|
||||
}, [
|
||||
bindings,
|
||||
cycleTheme,
|
||||
enabled,
|
||||
exitFocusMode,
|
||||
activeWorkspaceSelection,
|
||||
isDesktopApp,
|
||||
isMac,
|
||||
isMobile,
|
||||
isWorkspaceFocusModeEnabled,
|
||||
keyboardActionDispatcher,
|
||||
openProjectPickerAction,
|
||||
pathname,
|
||||
publishBrowserShortcutPolicy,
|
||||
handleBrowserShortcutInput,
|
||||
handleKeyDown,
|
||||
handleKeyUp,
|
||||
resetModifiers,
|
||||
router,
|
||||
shortcutsAvailable,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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: "在窗格中查找",
|
||||
|
||||
@@ -550,6 +550,36 @@ describe("keyboard-shortcuts", () => {
|
||||
expectNoShortcutResolution({ event, context });
|
||||
});
|
||||
|
||||
// A rebound pane-focus shortcut has to fire wherever the user is typing.
|
||||
// Its default combo carries `editable: false` so that Cmd+Shift+Arrow keeps
|
||||
// selecting text (the two cases above), and that guard describes the default
|
||||
// combo rather than the action, so it must not survive the rebind.
|
||||
describe("a rebound pane-focus shortcut", () => {
|
||||
const PANE_FOCUS_DOWN_BINDING = "workspace-pane-focus-down-cmd-shift-down";
|
||||
// macOS emits U+2206 for Option+J; the stored combo comes from the code.
|
||||
const altJ = { key: "\u2206", code: "KeyJ", altKey: true };
|
||||
|
||||
it.each(["message-input", "editable"] as const)("fires with %s focused", (focusScope) => {
|
||||
const result = resolveShortcut({
|
||||
event: altJ,
|
||||
context: { isMac: true, focusScope },
|
||||
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.pane.focus.down");
|
||||
});
|
||||
|
||||
it("still fires outside a text field", () => {
|
||||
const result = resolveShortcut({
|
||||
event: altJ,
|
||||
context: { isMac: true, focusScope: "other" },
|
||||
bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }),
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe("workspace.pane.focus.down");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
|
||||
const bindings = buildEffectiveBindings({
|
||||
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
|
||||
@@ -593,6 +623,42 @@ describe("keyboard-shortcuts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("completes a chord whose second step carries a modifier", () => {
|
||||
const bindings = buildEffectiveBindings({
|
||||
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K Ctrl+J",
|
||||
});
|
||||
|
||||
// The browser emits a keydown for the bare Control key before every combo,
|
||||
// so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two.
|
||||
const firstModifier = resolveShortcut({
|
||||
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
bindings,
|
||||
});
|
||||
const firstStep = resolveShortcut({
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: firstModifier.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
expect(firstStep.nextChordState.step).toBe(1);
|
||||
|
||||
const secondModifier = resolveShortcut({
|
||||
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: firstStep.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
const secondStep = resolveShortcut({
|
||||
event: { key: "j", code: "KeyJ", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: secondModifier.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(secondStep.match?.action).toBe("command-center.toggle");
|
||||
});
|
||||
|
||||
it("schedules a chord reset timeout for advancing candidates", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "@/keyboard/actions";
|
||||
import {
|
||||
chordStringToShortcutKeys,
|
||||
isModifierKeyCode,
|
||||
type KeyCombo,
|
||||
parseChordString,
|
||||
} from "@/keyboard/shortcut-string";
|
||||
@@ -1229,14 +1230,33 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
}
|
||||
const when = withoutDefaultComboGuard(binding.when);
|
||||
if (!binding.help?.defaultDisplayKeys) {
|
||||
return { ...binding, combo: override, parsedChord };
|
||||
return { ...binding, combo: override, parsedChord, when };
|
||||
}
|
||||
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
|
||||
return { ...binding, combo: override, parsedChord, help };
|
||||
return { ...binding, combo: override, parsedChord, when, help };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `editable: false` is a statement about a binding's *default* combo, not
|
||||
* about its action: the pane-focus defaults carry it so that Cmd+Shift+Arrow
|
||||
* keeps selecting text in a field instead of moving pane focus. An override
|
||||
* replaces that combo, so the guard no longer describes anything and has to
|
||||
* go, the same way `defaultDisplayKeys` does — otherwise the combo the user
|
||||
* picked in Settings silently refuses to fire wherever they are typing.
|
||||
*
|
||||
* The other guards stay. Platform, command center, terminal and focus scope
|
||||
* are properties of the action and of where it makes sense, and none of them
|
||||
* change because the keys did.
|
||||
*/
|
||||
function withoutDefaultComboGuard(when: ShortcutWhen | undefined): ShortcutWhen | undefined {
|
||||
if (when?.editable !== false) return when;
|
||||
const { editable: _editable, ...rest } = when;
|
||||
return rest;
|
||||
}
|
||||
|
||||
// --- Matching engine ---
|
||||
|
||||
function parseDigit(event: KeyboardShortcutInput): number | null {
|
||||
@@ -1517,6 +1537,13 @@ export function resolveKeyboardShortcut(input: {
|
||||
preventDefault: boolean;
|
||||
} {
|
||||
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
|
||||
// Pressing a modifier emits its own keydown before the combo that holds it,
|
||||
// so a chord waiting on `Ctrl+J` sees a bare `Control` first. That keydown
|
||||
// matches no combo, and resolving it would drop the chord back to its first
|
||||
// step. It decides nothing: leave the chord where it is.
|
||||
if (isModifierKeyCode(event.code)) {
|
||||
return { match: null, nextChordState: chordState, preventDefault: false };
|
||||
}
|
||||
if (chordState.step === 0) {
|
||||
return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings });
|
||||
}
|
||||
|
||||
@@ -245,8 +245,17 @@ export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
|
||||
return parts.length > 0 ? parts.join("+") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a keydown carries a modifier key itself rather than a key pressed
|
||||
* with one. The browser emits one of these before every combo that holds a
|
||||
* modifier, and it is the prefix of a combo, never a combo of its own.
|
||||
*/
|
||||
export function isModifierKeyCode(code: string): boolean {
|
||||
return MODIFIER_CODES.has(code);
|
||||
}
|
||||
|
||||
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
if (isModifierKeyCode(event.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
writes = 0;
|
||||
cleanups = 0;
|
||||
nextWriteFailure: Error | null = null;
|
||||
persistentWriteFailure: Error | null = null;
|
||||
readGate: Promise<void> | null = null;
|
||||
onRead: (() => void) | null = null;
|
||||
/** Throws once a read goes past this count, so a non-terminating read loop fails the test. */
|
||||
readLimit: number | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
@@ -47,6 +50,11 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
|
||||
if (this.readLimit !== null && this.reads.length > this.readLimit) {
|
||||
throw new Error(
|
||||
`replica cache read ${this.reads.length} passed the limit of ${this.readLimit}`,
|
||||
);
|
||||
}
|
||||
this.onRead?.();
|
||||
await this.readGate;
|
||||
const acceptedKinds = new Set(kinds);
|
||||
@@ -71,6 +79,7 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
|
||||
async apply(changes: ReplicaRowChanges): Promise<void> {
|
||||
this.writes += 1;
|
||||
if (this.persistentWriteFailure) throw this.persistentWriteFailure;
|
||||
if (this.nextWriteFailure) {
|
||||
const error = this.nextWriteFailure;
|
||||
this.nextWriteFailure = null;
|
||||
@@ -432,6 +441,34 @@ describe("ReplicaCache", () => {
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives up a read instead of retrying while the store keeps rejecting writes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
storage.persistentWriteFailure = new Error("QuotaExceededError");
|
||||
storage.reads.length = 0;
|
||||
storage.readLimit = 5;
|
||||
|
||||
deleteDirectory(cache, SERVER_ID);
|
||||
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
expect(storage.reads.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("still reads a host whose rows are stored when another host's write is rejected", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = new ReplicaCache(storage, noLegacyCleanup);
|
||||
cache.setHosts([SERVER_ID, "other-host"]);
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
storage.persistentWriteFailure = new Error("QuotaExceededError");
|
||||
|
||||
commitDirectory(cache, "other-host", directory());
|
||||
|
||||
expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.id).toBe("workspace-1");
|
||||
});
|
||||
|
||||
it("discards a durable read when the host changes while it is in flight", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
|
||||
@@ -983,7 +983,11 @@ export class ReplicaCache {
|
||||
try {
|
||||
await this.prepareStore();
|
||||
while (this.activeServerIds.has(serverId)) {
|
||||
await this.flush();
|
||||
// A read may only answer from rows the store already holds, so it waits for this host's
|
||||
// accepted commits to land. A store that rejects them will keep rejecting them: fail closed
|
||||
// rather than re-attempt the write on every pass. A write rejected for another host leaves
|
||||
// this host's stored rows readable.
|
||||
if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return [];
|
||||
const revision = this.hostRevisions.get(serverId) ?? 0;
|
||||
const rows = await this.rowStore.read(serverId, kinds, ids);
|
||||
if (this.canReadHostRevision(serverId, revision)) return rows;
|
||||
@@ -1173,20 +1177,26 @@ export class ReplicaCache {
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.persist();
|
||||
await this.syncPending();
|
||||
}
|
||||
|
||||
/** Resolves to whether every pending change reached the store. */
|
||||
private async syncPending(): Promise<boolean> {
|
||||
const persisted = await this.persist();
|
||||
await this.writeQueue.catch(() => undefined);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private async flushPending(): Promise<void> {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
private async persist(): Promise<boolean> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
if (!this.hasPendingChanges()) return;
|
||||
if (!this.hasPendingChanges()) return true;
|
||||
const write = this.writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
@@ -1206,11 +1216,16 @@ export class ReplicaCache {
|
||||
} catch {
|
||||
this.restorePendingChanges(pending);
|
||||
if (this.hasPendingChanges()) this.schedulePersist();
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
return true;
|
||||
});
|
||||
this.writeQueue = write;
|
||||
await write;
|
||||
// The queue only sequences writes; every consumer decides for itself what a failure means.
|
||||
this.writeQueue = write.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return write;
|
||||
}
|
||||
|
||||
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
|
||||
|
||||
@@ -377,8 +377,7 @@ export function KeyboardShortcutsSection() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (key === "Backspace") {
|
||||
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
|
||||
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user