Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bcb3157e75 test(app): isolate terminal pane settings mock 2026-05-31 12:45:29 +07:00
Mohamed Boudra de162b98d4 test(app): fix pane test mocks 2026-05-31 12:45:29 +07:00
Mohamed Boudra 0a960f9ae0 fix(app): reconcile find-in-pane with main after rebase
Resolve drift where main evolved the same areas this feature touches:

- view.tsx: keep main's agent-stream/ layout refactor, drop the
  working-indicator/turn-copy helpers main relocated, fix moved import paths
- terminal-emulator.tsx: expose terminal find on the typed ref handle only
  (main's DOMImperativeFactory is JSON-serializable-only)
- file-pane.tsx: render syntax highlighting and find highlighting together,
  pass lineSelection through the searchable preview
- tests/e2e: track main's PaneContextValue.openImportSheet,
  TerminalEmulatorHandle (restoreOutput/blur), and the seed-client helper
2026-05-31 12:45:29 +07:00
Mohamed Boudra 7c4f8580e9 test(app): broaden terminal find count assertion in find-in-pane spec
The terminal find pane matches the echoed printf command line in addition
to the printed output, so the actual count is 4 / 4. Match any non-zero
count, mirroring the chat assertion below it — this step only verifies
that find found something before clicking away.
2026-05-31 12:45:29 +07:00
Mohamed Boudra 33674e0b10 refactor(app): clean up find-in-pane slop
- A bridge: remove renderer webview find fallback and require the Electron bridge path

- B core: tighten pane-find assertions and hide internal search/render types

- C adapters: align chat/file search registration with highlightable content

- D harness: delete flaky Electron QA harness and simplify keyboard dispatcher scope
2026-05-31 12:45:29 +07:00
Mohamed Boudra 0ecc51bd99 feat(app): add find in pane across workspace panes 2026-05-31 12:45:29 +07:00
Mohamed Boudra 526e6070f8 feat(app): route Electron browser find through main-process owner WebContents 2026-05-31 12:45:29 +07:00
Mohamed Boudra 40d6a9127a Add Electron find QA harness 2026-05-31 12:45:29 +07:00
Mohamed Boudra 3d96ea6354 Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
2026-05-31 05:38:29 +00:00
Mohamed Boudra 0e3eb9cf75 Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker

Settings had two implicit tiers: app-level sections as flat sidebar rows,
and per-host settings hidden behind a drill-in to one monolithic host page.
This flattens both into a single sidebar with an "App" group and a "Host"
group, where the host's settings are top-level rows scoped by a host picker.

- Split the monolithic HostPage into four section pages: Connections,
  Orchestration (was the unnamed inject-tools + system-prompt block),
  Providers, and Daemon (status, updates, restart, remove).
- Host picker reuses the canonical <Combobox> sidebar host switcher; defaults
  to the local host so a single-local-host user never thinks about "the daemon".
- Host sections become real sub-routes (/settings/hosts/[id]/[section]); the
  old /settings/hosts/[id] redirects to the connections section, so existing
  deep links (pairing, workspace, open-project) keep working.
- "Open providers" from the project screen now lands on the Providers section.

* Fix e2e host-settings navigation for the flat sidebar

The flat-settings refactor moved host settings onto separate section pages,
but three e2e helpers still expected the old monolithic host page:

- openDesktopSettings: the daemon-lifecycle card now lives on the Daemon
  section — navigate there before asserting it.
- acp catalog: "Add provider" now lives on the Providers section — open that
  section before opening the add-provider modal.
- openAddHostFlow: "Add host" is now an item inside the host picker (Combobox),
  not a standalone button — open the picker first.

Test-only; no product changes. Fixes the 8 playwright failures on this branch.
2026-05-31 12:55:08 +08:00
Mohamed Boudra c8ca503cc3 Show agent terminals created in workspace subdirectories (#1245)
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.

getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
2026-05-31 12:29:05 +08:00
Mohamed Boudra 41cb1af036 Add handle pan-down fallback to bottom-sheet reopen e2e
The "model selector can open, close, reopen, and close again" test still flaked
on loaded CI runners even after re-tapping the backdrop: the model-selector
sheet re-renders as its model list settles, and Gorhom drops backdrop presses
during that churn, so the tap never dismisses within the retry window.

Fall back to dragging the sheet handle down, which drives Gorhom's pan-to-close
directly and is unaffected by the backdrop churn (mirrors the resilient close in
e2e/helpers/app.ts). The backdrop tap remains the primary path, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged. Verified locally by forcing the fallback (backdrop tap
disabled): the handle drag closes the sheet on its own.
2026-05-31 08:38:46 +07:00
Mohamed Boudra 17aa957d3e Stabilize bottom-sheet reopen e2e by retrying the backdrop tap (#1238)
The "model selector can open, close, reopen, and close again" test fails on
loaded CI runners: a single backdrop tap that lands while the sheet is still
present-animating is dropped (Gorhom ignores backdrop presses until the sheet
settles at its snap point), so the sheet never dismisses and the backdrop stays
visible. The reopen behaviour itself is correct — the visibility tracker is
unit-tested and the spec passes locally (3x), so this is test-interaction
fragility, not a product regression.

Re-tap the backdrop until the sheet dismisses, mirroring the resilient close
loop already used in e2e/helpers/app.ts. It stays a pure backdrop dismissal (no
Escape/pan fallback) so the real close path is still exercised, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged.
2026-05-30 22:59:02 +08:00
paseo-ai[bot] 2c756e9a8a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-30 02:53:33 +00:00
Mohamed Boudra bb6a4db6e0 chore(release): cut 0.1.87 2026-05-30 09:49:45 +07:00
Mohamed Boudra 2a13a082b7 Changelog for 0.1.87 2026-05-30 09:48:43 +07:00
Mohamed Boudra eea5932a21 Fix mobile bottom sheets reopening after dismiss 2026-05-30 09:43:49 +07:00
paseo-ai[bot] e04bb942e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 16:05:39 +00:00
Mohamed Boudra 3c3574d670 Fix intermittent Android crash by upgrading Reanimated to 4.3.1
Reanimated 4.1.x crashes on Android with a NullPointerException in
ViewGroup.dispatchDraw (ReactViewGroup.dispatchDraw) — a child view is
null mid-draw because Reanimated commits a ShadowTree change during the
Android draw pass, mutating the view hierarchy while it's being painted.
Fixed upstream in 4.3.0 (software-mansion/react-native-reanimated#9072).

4.3.x requires react-native-worklets 0.8.x, so worklets moves 0.5.1 ->
0.8.3 in lockstep. react-native is pinned to exact 0.81.5 so the
re-resolve can't pull 0.81.6 (which needs react ^19.1.4, conflicting with
the react 19.1.0 override), and reanimated/worklets are added to root
overrides to force a single version across the tree — two copies of the
worklets runtime throw a version-mismatch at startup.
2026-05-29 23:01:07 +07:00
Mohamed Boudra 44863ec1dd Forward subagent permission requests 2026-05-29 22:02:18 +07:00
Mohamed Boudra 47414abc5e website tweaks 2026-05-29 19:33:57 +07:00
paseo-ai[bot] 9860dd36ef fix: update lockfile signatures and Nix hash [skip ci] 2026-05-29 11:50:20 +00:00
95 changed files with 8157 additions and 775 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 0.1.87 - 2026-05-30
### Added
- Permission prompts from OpenCode subagents now surface in Paseo so you can approve or deny them
### Fixed
- Fixed an intermittent Android crash while animated views were drawing
- Fixed mobile bottom sheets not reopening after being dismissed
## 0.1.86 - 2026-05-29
### Added
+14
View File
@@ -155,6 +155,20 @@ The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Gotcha 6 — Bottom sheet refs are not lifecycle truth
`@gorhom/bottom-sheet` modals churn their imperative ref while presenting and
dismissing. Do not treat `ref != null` as permission to call `present()`, and do
not treat `ref == null` as the sheet being closed. The user-visible lifecycle is
the desired `visible` prop plus the sheet callbacks (`onChange(-1)`,
`onDismiss`).
If a user closes a sheet with the backdrop or a pan gesture, the sheet may detach
and reattach before React state has acknowledged `visible=false`. Re-presenting
on that attach races Gorhom's dismiss path and leaves the modal unable to reopen.
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
## Recipe for a new anchored panel
Before you write a new one, ask:
@@ -0,0 +1,149 @@
# Session God-File Decomposition Plan
`packages/server/src/server/session.ts` — 9116 lines, one `Session` class (declared line 724), 128 handlers, ~60 instance fields, 7 entangled domains. Goal: a **strictly behavior-preserving, incremental** decomposition into per-domain controllers, mirroring the existing `TerminalSessionController`.
## Chosen strategy: controller-context (per-domain option-bag controllers)
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
- An **options-bag constructor** injecting only what that domain reads.
- An **owned-type `ReadonlySet`** of message types.
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
- `start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
| 5 | WorkspaceController | XL | high |
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
| 7 | VoiceSessionController | XL | high |
| 8a | Agent-lifecycle config setters | M | medium |
| 8b | AgentLifecycleController | XL | high |
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:** `session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
---
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
In-place refactor on the shell, two named fan-outs:
1. **workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
2. **agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:** `session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 4 — GitCheckoutController
**Move:** ~22 `checkout_*`/`stash_*`/PR/github handlers + `handleSubscribeCheckoutDiffRequest`/`handleUnsubscribeCheckoutDiffRequest` + `emitCheckoutStatusUpdate` + `checkoutDiffSubscriptions` → `packages/server/src/server/checkout/git-checkout-controller.ts`. Collapse `dispatchCheckoutMessage`.
**SessionContext surface:** `emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:** `session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
---
## Slice 5 — WorkspaceController (XL)
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
**SessionContext surface:** `emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:** `session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
---
## Slice 6 — Voice prerequisites (emit purity + abort ownership)
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:** `voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
---
## Slice 8a — Agent-lifecycle config setters
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:** `session.test.ts`, `session.lifecycle-boundary.test.ts`.
## Slice 8b — AgentLifecycleController (XL, LAST)
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
**SessionContext surface:** `emit`, `sessionLogger`, `agentManager`/`agentStorage` (**owned**), injected `forwardAgentUpdate` → `buildProjectPlacementForCwd` (backed by WorkspaceController), `agentUpdatesSubscription` accessor (owned; workspace writes via `bufferAgentUpdate`), `isProviderVisibleToClient`, `resolveCreateAgentWorkspace`, `supports`, `mcpBaseUrl`, `terminalController.killTerminalForClose`.
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:** `session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
---
## Cross-cutting invariants (every slice)
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
- Controllers add **no** try/catch inside `dispatch` — error handling stays in `handleMessage`.
- `cleanup()` stays the single ordered teardown orchestrator on the shell.
- Move domain error emitters **verbatim**; treat any cross-domain emitter merge as a separate, test-guarded change.
- Per-slice typecheck/lint/format via `npm run` scripts; never re-run the full suite locally (run only the listed files with `--bail=1`).
+4 -2
View File
@@ -2,8 +2,8 @@
Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness.
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
@@ -24,6 +24,8 @@ Heartbeat is used for notification routing. It must not be used as a correctness
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
+1 -1
View File
@@ -1 +1 @@
sha256-PYbY3Lk9+y2byl1mN9dVO4YGXLEZrmnuPYxDw59LE5g=
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
+629 -47
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.86",
"version": "0.1.87",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.86",
"version": "0.1.87",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -10120,6 +10120,588 @@
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/metro-babel-transformer": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz",
"integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@react-native/babel-preset": "0.85.3",
"hermes-parser": "0.33.3",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-plugin-codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz",
"integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.29.0",
"@react-native/codegen": "0.85.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-preset": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz",
"integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-proposal-export-default-from": "^7.24.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-export-default-from": "^7.24.7",
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
"@babel/plugin-syntax-optional-chaining": "^7.8.3",
"@babel/plugin-transform-async-generator-functions": "^7.25.4",
"@babel/plugin-transform-async-to-generator": "^7.24.7",
"@babel/plugin-transform-block-scoping": "^7.25.0",
"@babel/plugin-transform-class-properties": "^7.25.4",
"@babel/plugin-transform-classes": "^7.25.4",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-flow-strip-types": "^7.25.2",
"@babel/plugin-transform-for-of": "^7.24.7",
"@babel/plugin-transform-modules-commonjs": "^7.24.8",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
"@babel/plugin-transform-optional-catch-binding": "^7.24.7",
"@babel/plugin-transform-optional-chaining": "^7.24.8",
"@babel/plugin-transform-private-methods": "^7.24.7",
"@babel/plugin-transform-private-property-in-object": "^7.24.7",
"@babel/plugin-transform-react-display-name": "^7.24.7",
"@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
"@babel/plugin-transform-react-jsx-source": "^7.24.7",
"@babel/plugin-transform-regenerator": "^7.24.7",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/plugin-transform-typescript": "^7.25.2",
"@babel/plugin-transform-unicode-regex": "^7.24.7",
"@react-native/babel-plugin-codegen": "0.85.3",
"babel-plugin-syntax-hermes-parser": "0.33.3",
"babel-plugin-transform-flow-enums": "^0.0.2",
"react-refresh": "^0.14.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz",
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.29.0",
"hermes-parser": "0.33.3",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"tinyglobby": "^0.2.15",
"yargs": "^17.6.2"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-parser": "0.33.3"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
"integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
"integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.33.3"
}
},
"node_modules/@react-native/metro-babel-transformer/node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-native/metro-config": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz",
"integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.85.3",
"@react-native/metro-babel-transformer": "0.85.3",
"metro-config": "^0.84.3",
"metro-runtime": "^0.84.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@react-native/metro-config/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/@react-native/metro-config/node_modules/ci-info": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-config/node_modules/hermes-estree": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz",
"integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==",
"license": "MIT",
"peer": true
},
"node_modules/@react-native/metro-config/node_modules/hermes-parser": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz",
"integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hermes-estree": "0.35.0"
}
},
"node_modules/@react-native/metro-config/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@react-native/metro-config/node_modules/metro": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz",
"integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"accepts": "^2.0.0",
"ci-info": "^2.0.0",
"connect": "^3.6.5",
"debug": "^4.4.0",
"error-stack-parser": "^2.0.6",
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"hermes-parser": "0.35.0",
"image-size": "^1.0.2",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"jsc-safe-url": "^0.2.2",
"lodash.throttle": "^4.1.1",
"metro-babel-transformer": "0.84.4",
"metro-cache": "0.84.4",
"metro-cache-key": "0.84.4",
"metro-config": "0.84.4",
"metro-core": "0.84.4",
"metro-file-map": "0.84.4",
"metro-resolver": "0.84.4",
"metro-runtime": "0.84.4",
"metro-source-map": "0.84.4",
"metro-symbolicate": "0.84.4",
"metro-transform-plugins": "0.84.4",
"metro-transform-worker": "0.84.4",
"mime-types": "^3.0.1",
"nullthrows": "^1.1.1",
"serialize-error": "^2.1.0",
"source-map": "^0.5.6",
"throat": "^5.0.0",
"ws": "^7.5.10",
"yargs": "^17.6.2"
},
"bin": {
"metro": "src/cli.js"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-babel-transformer": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz",
"integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"flow-enums-runtime": "^0.0.6",
"hermes-parser": "0.35.0",
"metro-cache-key": "0.84.4",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-cache": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz",
"integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"exponential-backoff": "^3.1.1",
"flow-enums-runtime": "^0.0.6",
"https-proxy-agent": "^7.0.5",
"metro-core": "0.84.4"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-cache-key": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz",
"integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-config": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz",
"integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"connect": "^3.6.5",
"flow-enums-runtime": "^0.0.6",
"jest-validate": "^29.7.0",
"metro": "0.84.4",
"metro-cache": "0.84.4",
"metro-core": "0.84.4",
"metro-runtime": "0.84.4",
"yaml": "^2.6.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-core": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz",
"integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"lodash.throttle": "^4.1.1",
"metro-resolver": "0.84.4"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-file-map": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz",
"integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"fb-watchman": "^2.0.0",
"flow-enums-runtime": "^0.0.6",
"graceful-fs": "^4.2.4",
"invariant": "^2.2.4",
"jest-worker": "^29.7.0",
"micromatch": "^4.0.4",
"nullthrows": "^1.1.1",
"walker": "^1.0.7"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-minify-terser": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz",
"integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"terser": "^5.15.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-resolver": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz",
"integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-runtime": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz",
"integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.25.0",
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-source-map": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz",
"integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"invariant": "^2.2.4",
"metro-symbolicate": "0.84.4",
"nullthrows": "^1.1.1",
"ob1": "0.84.4",
"source-map": "^0.5.6",
"vlq": "^1.0.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-symbolicate": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz",
"integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6",
"invariant": "^2.2.4",
"metro-source-map": "0.84.4",
"nullthrows": "^1.1.1",
"source-map": "^0.5.6",
"vlq": "^1.0.0"
},
"bin": {
"metro-symbolicate": "src/index.js"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-transform-plugins": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz",
"integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/metro-transform-worker": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz",
"integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"flow-enums-runtime": "^0.0.6",
"metro": "0.84.4",
"metro-babel-transformer": "0.84.4",
"metro-cache": "0.84.4",
"metro-cache-key": "0.84.4",
"metro-minify-terser": "0.84.4",
"metro-source-map": "0.84.4",
"metro-transform-plugins": "0.84.4",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@react-native/metro-config/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@react-native/metro-config/node_modules/ob1": {
"version": "0.84.4",
"resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz",
"integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"flow-enums-runtime": "^0.0.6"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@react-native/metro-config/node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@react-native/normalize-colors": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.6.tgz",
@@ -30808,19 +31390,18 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
"integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1",
"semver": "7.7.2"
"react-native-is-edge-to-edge": "^1.3.1",
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"react": "*",
"react-native": "*",
"react-native-worklets": ">=0.5.0"
"react-native": "0.81 - 0.85",
"react-native-worklets": "0.8.x"
}
},
"node_modules/react-native-reanimated/node_modules/semver": {
@@ -30962,27 +31543,28 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
"@babel/plugin-transform-classes": "^7.0.0-0",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
"@babel/plugin-transform-optional-chaining": "^7.0.0-0",
"@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
"@babel/plugin-transform-template-literals": "^7.0.0-0",
"@babel/plugin-transform-unicode-regex": "^7.0.0-0",
"@babel/preset-typescript": "^7.16.7",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-classes": "^7.28.4",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"convert-source-map": "^2.0.0",
"semver": "7.7.2"
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0",
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "*"
"react-native": "0.81 - 0.85"
}
},
"node_modules/react-native-worklets/node_modules/semver": {
@@ -36303,7 +36885,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36360,14 +36942,14 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
@@ -36375,7 +36957,7 @@
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
@@ -36528,12 +37110,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/server": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/server": "0.1.87",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36779,10 +37361,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -36802,7 +37384,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.86",
"version": "0.1.87",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37054,7 +37636,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.86",
"version": "0.1.87",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37090,7 +37672,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37319,7 +37901,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37340,7 +37922,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37558,14 +38140,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.86",
"@getpaseo/highlight": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/highlight": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38337,7 +38919,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.86",
"version": "0.1.87",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -121,6 +121,8 @@
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-native-reanimated": "4.3.1",
"react-native-worklets": "0.8.3"
}
}
@@ -6,6 +6,7 @@ import {
installAcpCatalogProvider,
openAddProviderModal,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
@@ -18,6 +19,8 @@ test.describe("ACP provider catalog", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
@@ -0,0 +1,126 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "bottom-sheet-reopen-",
title: "Bottom sheet reopen e2e",
initialPrompt: "Prepare a bottom sheet reopen test agent.",
});
await openAgentRoute(page, session);
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await run();
} finally {
await session.cleanup();
}
}
function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
}
function bottomSheetHandle(page: Page) {
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
}
async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
}
async function closeBottomSheetWithBackdrop(page: Page) {
const backdrop = bottomSheetBackdrop(page);
const handle = bottomSheetHandle(page);
// Tapping the backdrop is the close path under test, but on a loaded CI runner
// the model-selector sheet re-renders as its model list settles and Gorhom
// drops backdrop presses during that churn — so a tap (even retried) can fail
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
// The post-close guard below still protects the regression this test exists
// for: a sheet that dismisses, then re-presents.
await expect(async () => {
if (!(await backdrop.isVisible())) {
return;
}
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await page.waitForTimeout(150);
if (await backdrop.isVisible()) {
const handleBox = await handle.boundingBox();
if (handleBox) {
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
// Guard against the regression where the sheet starts dismissing, then re-presents.
await page.waitForTimeout(500);
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}
async function openTabSwitcher(page: Page) {
const trigger = page.getByRole("button", { name: /Switch tabs/ });
await trigger.click();
await expectBottomSheetOpen(page);
}
async function openModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expectBottomSheetOpen(page);
await expect(
page.getByLabel("Bottom Sheet", { exact: true }).getByText("Ten second stream", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
}
async function openAndCloseModelSelectorTwice(page: Page) {
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
await openModelSelector(page);
await closeBottomSheetWithBackdrop(page);
}
test.describe("mobile bottom sheet reopen", () => {
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseTabSwitcherTwice(page);
});
});
test("model selector can open, close, reopen, and close again", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openAndCloseModelSelectorTwice(page);
});
});
});
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function findShortcut(): string {
return process.platform === "darwin" ? "Meta+f" : "Control+f";
}
async function navigateToWorkspaceViaSidebar(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
async function openFind(page: Page): Promise<void> {
await page.keyboard.press(findShortcut());
await expect(page.getByTestId("pane-find-bar")).toBeVisible({ timeout: 10_000 });
}
async function typeFindQuery(page: Page, query: string): Promise<void> {
const input = page.getByTestId("pane-find-input");
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill(query);
}
test.describe("in-pane find", () => {
test("walks chat, file, terminal, split-pane, and browser-web find flows in the running app", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const client = await connectWorkspaceSetupClient();
const agentClient = await connectSeedClient();
const repo = await createTempGitRepo("find-pane-qa-", {
files: [
{
path: "src/find-target.txt",
content: [
"alpha needle first",
"beta without match",
"gamma NEEDLE second",
"delta needle third",
"",
].join("\n"),
},
],
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceResult.workspace.id);
await openFileExplorer(page);
await openFileFromExplorer(page, "src");
await openFileFromExplorer(page, "find-target.txt");
await expect(page.getByTestId("workspace-file-pane")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("workspace-file-pane").click();
await openFind(page);
await typeFindQuery(page, "needle");
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pane-find-next").click();
await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pane-find-prev").click();
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pane-find-input").focus();
await page.keyboard.press("Enter");
await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Shift+Enter");
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
await typeFindQuery(page, "missing-value");
await expect(page.getByText("No matches")).toBeVisible({ timeout: 10_000 });
await typeFindQuery(page, "");
await expect(page.getByText("0 / 0")).toBeVisible({ timeout: 10_000 });
await typeFindQuery(page, "needle");
await page.keyboard.press("Escape");
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
await testInfo.attach("file-find-walkthrough", {
body: await page.screenshot(),
contentType: "image/png",
});
await page.getByLabel("Split pane right").filter({ visible: true }).last().click();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await page.getByTestId("workspace-new-terminal").filter({ visible: true }).last().click();
const splitTerminal = page.getByTestId("terminal-surface").last();
await expect(splitTerminal).toBeVisible({ timeout: 20_000 });
await splitTerminal.click();
await setupDeterministicPrompt(page, `SPLIT_FIND_READY_${Date.now()}`);
await splitTerminal.pressSequentially("printf 'split needle one\\nsplit needle two\\n'\n", {
delay: 0,
});
await waitForTerminalContent(page, (text) => text.includes("split needle two"), 10_000);
await openFind(page);
await typeFindQuery(page, "needle");
await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("workspace-file-pane").click();
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
await openFind(page);
await typeFindQuery(page, "needle");
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pane-find-close").click();
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
await testInfo.attach("terminal-find-walkthrough", {
body: await page.screenshot(),
contentType: "image/png",
});
await testInfo.attach("split-pane-focus-switching", {
body: await page.screenshot(),
contentType: "image/png",
});
const agent = await agentClient.createAgent({
provider: "mock",
cwd: repo.path,
title: "Find pane QA",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "chat needle alpha",
});
await page.goto(
`${buildHostWorkspaceRoute(getServerId(), repo.path)}?open=${encodeURIComponent(
`agent:${agent.id}`,
)}`,
);
await expect(page.getByText("chat needle alpha").first()).toBeVisible({
timeout: 30_000,
});
await page.getByText("chat needle alpha").first().click();
await openFind(page);
await typeFindQuery(page, "needle");
await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pane-find-next").click();
await page.getByTestId("pane-find-prev").click();
await page.getByTestId("pane-find-close").click();
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
await testInfo.attach("chat-find-walkthrough", {
body: await page.screenshot(),
contentType: "image/png",
});
await page.getByTestId("workspace-header-menu-trigger").click();
const browserMenuItem = page.getByTestId("workspace-header-new-browser");
if (await browserMenuItem.isVisible().catch(() => false)) {
await browserMenuItem.click();
await expect(
page.getByText("Open this workspace in Electron to use the built-in browser."),
).toBeVisible({ timeout: 10_000 });
await page.keyboard.press(findShortcut());
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
await testInfo.attach("browser-web-fallback", {
body: await page.screenshot(),
contentType: "image/png",
});
} else {
await testInfo.attach("browser-web-fallback", {
body: "Browser tab creation is not exposed in this browser-web runtime.",
contentType: "text/plain",
});
}
} finally {
await agentClient.close();
await client.close();
await repo.cleanup();
}
});
});
+4 -1
View File
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost } from "./settings";
import { openSettingsHost, openSettingsHostSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -208,6 +208,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Daemon section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "daemon");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});
+2 -2
View File
@@ -320,12 +320,12 @@ async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefin
const client = handle.client as SeedDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
limit: 0,
});
return timeline.epoch;
+68 -21
View File
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
@@ -13,6 +14,8 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
@@ -28,8 +31,22 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
await page.getByTestId("settings-host-section-connections").click();
await expectHostSettingsUrl(page, serverId);
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function openSettingsHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -37,6 +54,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
}
export async function openAddHostFlow(page: Page): Promise<void> {
// "Add host" is now an item inside the host picker (a Combobox); open the
// picker first, then pick it. The picker renders whenever a host exists.
await page.getByTestId("settings-host-picker").click();
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
}
@@ -68,7 +88,7 @@ export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
@@ -105,7 +125,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
);
}
@@ -181,7 +201,11 @@ export async function expectHostLabelEditMode(page: Page, expectedLabel: string)
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
// "Connections" appears three times on this page: the sidebar section row, the
// detail header title, and the SettingsSection heading above the card. Match
// the first to keep the heading assertion without tripping Playwright strict
// mode.
await expect(page.getByText("Connections", { exact: true }).first()).toBeVisible();
await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
@@ -193,14 +217,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
export async function expectHostActionCards(page: Page): Promise<void> {
export async function openHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await openSettingsHostSection(page, serverId, section);
}
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
// Restart + remove cards live on the Daemon section; providers moved to its
// own Providers section (asserted via expectHostProvidersCard).
await openSettingsHostSection(page, serverId, "daemon");
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
}
export async function expectHostProvidersCard(page: Page, serverId: string): Promise<void> {
await openSettingsHostSection(page, serverId, "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
}
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
await page.route(url, async (route) => {
await route.fulfill({
@@ -244,27 +283,35 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// App group rows remain top-level.
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
}
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
// open) that surfaces the local host by its label. The "Local" marker only
// appears on dropdown rows in the multi-host case, which this fixture does not
// exercise.
const picker = sidebar.getByTestId("settings-host-picker");
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
}
+47 -11
View File
@@ -6,12 +6,14 @@ import { getServerId } from "./helpers/server-id";
import {
expectSettingsHeader,
openSettingsHost,
openHostSection,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
@@ -19,9 +21,7 @@ import {
} from "./helpers/settings";
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
test("connections section shows the seeded connection endpoint", async ({ page }) => {
const serverId = getServerId();
const port = getE2EDaemonPort();
@@ -29,11 +29,44 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openSettingsHost(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectSettingsHeader(page, "Connections");
await expectHostConnectionsCard(page, port);
});
test("orchestration section shows the inject MCP toggle", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "orchestration");
await expectSettingsHeader(page, "Orchestration");
await expectHostInjectMcpCard(page);
await expectHostActionCards(page);
});
test("providers section shows the providers card", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
test("daemon section shows the host label and restart/remove action cards", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectSettingsHeader(page, "Daemon");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
@@ -42,13 +75,14 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getServerId();
@@ -56,19 +90,20 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
const serverId = getServerId();
@@ -77,9 +112,10 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page);
await expectHostActionCards(page, serverId);
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
+4 -2
View File
@@ -45,7 +45,7 @@ test.describe("Settings sidebar navigation", () => {
await expectGeneralContent(page);
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
});
@@ -132,7 +132,9 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsBackButton(page);
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"main": "index.ts",
"scripts": {
@@ -85,14 +85,14 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
@@ -100,7 +100,7 @@
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
@@ -32,11 +32,30 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80;
interface NativeScrollToIndexFailedInfo {
index: number;
highestMeasuredFrameIndex: number;
averageItemLength: number;
}
interface NativeScrollIndexFallbackInput {
index: number;
averageItemLength: number;
}
function keyExtractor(item: { id: string }): string {
return item.id;
}
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollIndexFallbackInput) {
if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) {
return 0;
}
return Math.max(0, input.averageItemLength * input.index);
}
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const {
agentId,
@@ -69,6 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const scrollToIndexRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
@@ -87,6 +107,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearScrollToIndexRetry = useCallback(() => {
if (scrollToIndexRetryTimeoutRef.current !== null) {
clearTimeout(scrollToIndexRetryTimeoutRef.current);
scrollToIndexRetryTimeoutRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -149,6 +176,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollToBottom,
});
const scrollToHistoryIndex = useCallback(
(index: number) => {
programmaticScrollEventBudgetRef.current = 3;
clearScrollToIndexRetry();
flatListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0.5,
});
},
[clearScrollToIndexRetry],
);
const handleScrollToIndexFailed = useStableEvent((info: NativeScrollToIndexFailedInfo) => {
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: getNativeScrollToIndexFallbackOffset(info),
animated: true,
});
clearScrollToIndexRetry();
scrollToIndexRetryTimeoutRef.current = setTimeout(() => {
scrollToIndexRetryTimeoutRef.current = null;
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
viewPosition: 0.5,
});
}, SCROLL_TO_INDEX_RETRY_DELAY_MS);
});
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
@@ -161,6 +219,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
clearScrollToIndexRetry();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => {
@@ -169,7 +228,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
};
}, [agentId, clearNativeViewportSettling]);
}, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]);
useEffect(() => {
const keyboardEvents = [
@@ -190,8 +249,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
subscription.remove();
}
clearNativeViewportSettling();
clearScrollToIndexRetry();
};
}, [clearNativeViewportSettling, markNativeViewportSettling]);
}, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
@@ -209,6 +269,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
scrollToStreamItem: (target) => {
programmaticScrollEventBudgetRef.current = 3;
if (target.source === "liveHead") {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
return;
}
scrollToHistoryIndex(target.index);
},
};
viewportRef.current = handle;
return () => {
@@ -216,7 +287,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
}, [
agentId,
bottomAnchorController,
markNativeViewportSettling,
scrollToHistoryIndex,
viewportRef,
]);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
@@ -360,6 +437,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={handleScrollToIndexFailed}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40}
maxToRenderPerBatch={40}
+33 -5
View File
@@ -1,5 +1,4 @@
import React, {
Fragment,
type CSSProperties,
useCallback,
useEffect,
@@ -67,6 +66,10 @@ function scrollElementToBottom(
});
}
function getStreamItemElementId(itemId: string): string {
return `agent-stream-row-${itemId}`;
}
function syncNearBottom(
scrollContainer: HTMLElement | null,
onNearBottomChange: (value: boolean) => void,
@@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
scrollToStreamItem: (target) => {
setFollowOutput(false);
cancelPendingStickToBottom();
if (target.source === "historyVirtualized") {
rowVirtualizer.scrollToIndex(target.index, { align: "center" });
return;
}
const row = document.getElementById(getStreamItemElementId(target.itemId));
row?.scrollIntoView({ block: "center", behavior: "auto" });
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
syncNearBottom(scrollContainer, onNearBottomChange);
}
},
};
viewportRef.current = handle;
return () => {
@@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom();
};
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
}, [
cancelPendingStickToBottom,
forceStickToBottom,
onNearBottomChange,
rowVirtualizer,
scheduleStickToBottom,
viewportRef,
]);
const contentContainerStyle = useMemo((): CSSProperties => {
return {
@@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
<div id={getStreamItemElementId(item.id)} key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
</div>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
<div id={getStreamItemElementId(item.id)} key={item.id}>
{renderLiveHeadRow(item, index, segments.liveHead)}
</div>
));
}, [renderLiveHeadRow, segments.liveHead]);
const liveAuxiliary = useMemo(() => {
@@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div
key={virtualRow.key}
data-index={virtualRow.index}
id={getStreamItemElementId(item.id)}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps {
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToStreamItem: (target: StreamScrollTarget) => void;
}
export interface StreamScrollTarget {
source: "historyVirtualized" | "historyMounted" | "liveHead";
index: number;
itemId: string;
}
export interface StreamSegmentRenderers {
+239 -2
View File
@@ -36,6 +36,7 @@ import {
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
type MessageFindHighlight,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
@@ -58,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
type AgentStreamSearchMatch,
} from "@/components/agent-stream-search-model";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
@@ -75,6 +81,12 @@ import {
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -197,6 +209,69 @@ function renderLiveHeadStreamItem(input: {
return input.renderStreamItem(layoutItem);
}
function createAgentStreamFindMatchState(input: {
query: string;
matches: AgentStreamSearchMatch[];
currentMatchId: string | null;
}): PaneFindMatchState {
if (input.query.length === 0) {
return { status: "empty" };
}
if (input.matches.length === 0) {
return { status: "no-match" };
}
const currentIndex = Math.max(
0,
input.matches.findIndex((match) => match.id === input.currentMatchId),
);
return {
status: "matched",
current: currentIndex + 1,
total: input.matches.length,
};
}
function createAgentStreamFindHighlightsByItemId(input: {
matches: AgentStreamSearchMatch[];
currentMatchId: string | null;
}): Map<string, MessageFindHighlight[]> {
const highlightsByItemId = new Map<string, MessageFindHighlight[]>();
for (const match of input.matches) {
if (match.segmentKey !== "text") {
continue;
}
const itemKind = match.entry.item.kind;
if (itemKind !== "user_message" && itemKind !== "assistant_message") {
continue;
}
const highlights = highlightsByItemId.get(match.entry.item.id) ?? [];
highlights.push({
id: match.id,
start: match.start,
end: match.end,
isCurrent: match.id === input.currentMatchId,
});
highlightsByItemId.set(match.entry.item.id, highlights);
}
return highlightsByItemId;
}
interface AgentStreamFindState {
query: string;
matches: AgentStreamSearchMatch[];
currentMatchId: string | null;
}
const EMPTY_AGENT_STREAM_FIND_STATE: AgentStreamFindState = {
query: "",
matches: [],
currentMatchId: null,
};
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -373,6 +448,154 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamRenderStrategy,
],
);
const searchModel = useMemo(
() =>
buildAgentStreamSearchModel({
streamItems,
streamHead: streamHead ?? [],
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
cwd: agent.cwd,
}),
[agent.cwd, isMobile, streamHead, streamItems],
);
const [findState, setFindState] = useState<AgentStreamFindState>(EMPTY_AGENT_STREAM_FIND_STATE);
const findQuery = findState.query;
const findMatches = findState.matches;
const currentFindMatchId = findState.currentMatchId;
const currentFindMatchIndex = useMemo(() => {
if (findMatches.length === 0) {
return -1;
}
const existingIndex = findMatches.findIndex((match) => match.id === currentFindMatchId);
return existingIndex >= 0 ? existingIndex : 0;
}, [currentFindMatchId, findMatches]);
const findMatchState = useMemo(
() =>
createAgentStreamFindMatchState({
query: findQuery,
matches: findMatches,
currentMatchId: currentFindMatchId,
}),
[currentFindMatchId, findMatches, findQuery],
);
const findHighlightsByItemId = useMemo(
() =>
createAgentStreamFindHighlightsByItemId({
matches: findMatches,
currentMatchId: currentFindMatchId,
}),
[currentFindMatchId, findMatches],
);
const scrollFindMatchIntoView = useCallback((match: AgentStreamSearchMatch | undefined) => {
if (!match) {
return;
}
viewportRef.current?.scrollToStreamItem({
source: match.entry.source,
index: match.entry.index,
itemId: match.entry.item.id,
});
}, []);
const paneFind = usePaneFind({
matchState: findMatchState,
onQuery: (query) => {
const nextMatches = findAgentStreamSearchMatches({
model: searchModel,
query,
});
const nextMatchId = nextMatches[0]?.id ?? null;
setFindState({ query, matches: nextMatches, currentMatchId: nextMatchId });
scrollFindMatchIntoView(nextMatches[0]);
return createAgentStreamFindMatchState({
query,
matches: nextMatches,
currentMatchId: nextMatchId,
});
},
onNext: () => {
if (findMatches.length === 0) {
return findMatchState;
}
const nextIndex = (currentFindMatchIndex + 1) % findMatches.length;
const nextMatch = findMatches[nextIndex];
setFindState((current) => ({
...current,
currentMatchId: nextMatch?.id ?? null,
}));
scrollFindMatchIntoView(nextMatch);
return createAgentStreamFindMatchState({
query: findQuery,
matches: findMatches,
currentMatchId: nextMatch?.id ?? null,
});
},
onPrev: () => {
if (findMatches.length === 0) {
return findMatchState;
}
const nextIndex = (currentFindMatchIndex - 1 + findMatches.length) % findMatches.length;
const nextMatch = findMatches[nextIndex];
setFindState((current) => ({
...current,
currentMatchId: nextMatch?.id ?? null,
}));
scrollFindMatchIntoView(nextMatch);
return createAgentStreamFindMatchState({
query: findQuery,
matches: findMatches,
currentMatchId: nextMatch?.id ?? null,
});
},
onClose: () => {
setFindState(EMPTY_AGENT_STREAM_FIND_STATE);
},
});
useEffect(() => {
setFindState((current) => {
if (current.query.length === 0) {
return current.currentMatchId === null && current.matches.length === 0
? current
: EMPTY_AGENT_STREAM_FIND_STATE;
}
const nextMatches = findAgentStreamSearchMatches({
model: searchModel,
query: current.query,
});
let keepsCurrentMatch = false;
if (current.currentMatchId) {
for (const match of nextMatches) {
if (match.id === current.currentMatchId) {
keepsCurrentMatch = true;
break;
}
}
}
const nextMatchId = keepsCurrentMatch
? current.currentMatchId
: (nextMatches[0]?.id ?? null);
let matchesUnchanged = current.matches.length === nextMatches.length;
if (matchesUnchanged) {
for (let index = 0; index < current.matches.length; index += 1) {
if (current.matches[index]?.id !== nextMatches[index]?.id) {
matchesUnchanged = false;
break;
}
}
}
if (matchesUnchanged && current.currentMatchId === nextMatchId) {
return current;
}
return {
query: current.query,
matches: nextMatches,
currentMatchId: nextMatchId,
};
});
}, [searchModel]);
useImperativeHandle(
ref,
() => ({
@@ -423,10 +646,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
findHighlights={findHighlightsByItemId.get(item.id)}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -446,11 +670,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
findHighlights={findHighlightsByItemId.get(item.id)}
/>
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[
client,
findHighlightsByItemId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
);
const renderThoughtItem = useCallback(
@@ -700,6 +932,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<AgentStreamFindBarSlot paneFind={paneFind} />
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
@@ -748,6 +981,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
+2 -1
View File
@@ -867,7 +867,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="settings/hosts/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>
);
}
@@ -1,16 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,19 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
export default function SettingsHostSectionRoute() {
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections";
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,13 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildSettingsHostSectionRoute, buildSettingsRoute } from "@/utils/host-routes";
export default function SettingsHostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
if (!serverId) {
return <Redirect href={buildSettingsRoute()} />;
}
return <Redirect href={buildSettingsHostSectionRoute(serverId, "connections")} />;
}
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
} from "./agent-stream-search-model";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, text: string, seed = 1): StreamItem {
return {
kind: "user_message",
id,
text,
timestamp: timestamp(seed),
};
}
function assistantMessage(id: string, text: string, seed = 1): StreamItem {
return {
kind: "assistant_message",
id,
text,
timestamp: timestamp(seed),
};
}
function thought(id: string, text: string, seed = 1): StreamItem {
return {
kind: "thought",
id,
text,
status: "ready",
timestamp: timestamp(seed),
};
}
function activityLog(id: string, message: string, seed = 1): StreamItem {
return {
kind: "activity_log",
id,
activityType: "info",
message,
metadata: { hidden: "metadata is not searched" },
timestamp: timestamp(seed),
};
}
function todoList(id: string, seed = 1): StreamItem {
return {
kind: "todo_list",
id,
provider: "codex",
items: [
{ text: "Write the red test", completed: true },
{ text: "Make search green", completed: false },
],
timestamp: timestamp(seed),
};
}
function getSearchableText(item: StreamItem): string {
const model = buildAgentStreamSearchModel({
streamItems: [item],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
return model.entries[0]?.text ?? "";
}
describe("buildAgentStreamSearchModel", () => {
it("indexes user and assistant message text", () => {
expect(getSearchableText(userMessage("u1", "user text"))).toBe("user text");
expect(getSearchableText(assistantMessage("a1", "assistant text"))).toBe("assistant text");
});
it("excludes non-message rows that do not render find highlights", () => {
expect(getSearchableText(thought("t1", "thought text"))).toBe("");
expect(getSearchableText(activityLog("l1", "activity text"))).toBe("");
expect(getSearchableText(todoList("todo"))).toBe("");
});
it("excludes tool call rows from the search index", () => {
const toolCall: StreamItem = {
kind: "tool_call",
id: "shell",
timestamp: timestamp(1),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "call-shell",
name: "exec_command",
status: "completed",
error: null,
detail: {
type: "shell",
command: "npm run typecheck",
output: "internal output should stay out",
},
},
},
};
expect(getSearchableText(toolCall)).toBe("");
});
it("orders virtualized history, mounted history, live head, and optimistic items deterministically", () => {
const committed: StreamItem[] = [];
for (let index = 0; index < 64; index += 1) {
committed.push(userMessage(`u${index}`, `history ${index}`, index));
}
const optimistic = userMessage("optimistic", "draft message", 65);
const liveHead = [assistantMessage("live", "live answer", 66)];
const model = buildAgentStreamSearchModel({
streamItems: committed,
optimisticItems: [optimistic],
streamHead: liveHead,
platform: "web",
isMobileBreakpoint: false,
});
expect(model.entries.at(0)?.item.id).toBe("optimistic");
expect(model.entries.map((entry) => entry.source)).toEqual([
...Array.from(
{ length: model.segments.historyVirtualized.length },
() => "historyVirtualized",
),
...Array.from({ length: model.segments.historyMounted.length }, () => "historyMounted"),
"liveHead",
]);
expect(model.entries.at(-1)?.item.id).toBe("live");
});
it("does not duplicate an optimistic item once committed history has the same id", () => {
const committed = [userMessage("u1", "committed draft")];
const model = buildAgentStreamSearchModel({
streamItems: committed,
optimisticItems: [userMessage("u1", "optimistic draft")],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
expect(model.entries.map((entry) => entry.text)).toEqual(["committed draft"]);
});
});
describe("findAgentStreamSearchMatches", () => {
it("returns stable match ids from item identity and local occurrence data", () => {
const model = buildAgentStreamSearchModel({
streamItems: [assistantMessage("a1", "Alpha alpha beta")],
streamHead: [assistantMessage("h1", "alpha live")],
platform: "web",
isMobileBreakpoint: true,
});
const matches = findAgentStreamSearchMatches({
model,
query: "alpha",
});
expect(matches.map((match) => match.id)).toEqual([
"a1:text:0:0:5",
"a1:text:1:6:11",
"h1:text:0:0:5",
]);
expect(matches.map((match) => match.entry.item.id)).toEqual(["a1", "a1", "h1"]);
});
it("skips fenced code blocks while preserving message offsets for highlights", () => {
const model = buildAgentStreamSearchModel({
streamItems: [assistantMessage("a1", "before alpha\n```\nalpha\n```\nafter alpha")],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
const matches = findAgentStreamSearchMatches({
model,
query: "alpha",
});
expect(matches.map((match) => match.id)).toEqual(["a1:text:0:7:12", "a1:text:0:33:38"]);
});
});
@@ -0,0 +1,276 @@
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "@/agent-stream/web-virtualization";
type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
interface AgentStreamSearchTextSegment {
key: string;
text: string;
startOffset: number;
}
interface AgentStreamSearchEntry {
item: StreamItem;
source: AgentStreamSearchSource;
index: number;
text: string;
segments: AgentStreamSearchTextSegment[];
}
export interface AgentStreamSearchMatch {
id: string;
entry: AgentStreamSearchEntry;
segmentKey: string;
occurrenceIndex: number;
start: number;
end: number;
}
interface AgentStreamSearchModel {
entries: AgentStreamSearchEntry[];
segments: {
historyVirtualized: AgentStreamSearchEntry[];
historyMounted: AgentStreamSearchEntry[];
liveHead: AgentStreamSearchEntry[];
};
}
interface BuildAgentStreamSearchModelInput {
platform: "web" | "native";
isMobileBreakpoint: boolean;
streamItems: StreamItem[];
streamHead: StreamItem[];
optimisticItems?: StreamItem[];
cwd?: string;
}
interface FindAgentStreamSearchMatchesInput {
model: AgentStreamSearchModel;
query: string;
}
function getFenceDelimiter(line: string): string | null {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
return match?.[2] ?? null;
}
function getMessageSearchableSegments(text: string): AgentStreamSearchTextSegment[] {
const segments: AgentStreamSearchTextSegment[] = [];
let activeFenceCharacter: "`" | "~" | null = null;
let activeFenceLength = 0;
let currentText = "";
let currentStartOffset = 0;
let offset = 0;
const flush = () => {
if (currentText.length > 0) {
segments.push({ key: "text", text: currentText, startOffset: currentStartOffset });
currentText = "";
}
};
for (const line of text.split("\n")) {
const lineWithBreak = offset + line.length < text.length ? `${line}\n` : line;
const fenceDelimiter = getFenceDelimiter(line);
const isClosingFence =
activeFenceCharacter &&
fenceDelimiter?.[0] === activeFenceCharacter &&
fenceDelimiter.length >= activeFenceLength;
const isOpeningFence = !activeFenceCharacter && fenceDelimiter;
const isIndentedCode = !activeFenceCharacter && (/^( {4,}|\t)/.test(line) || line === " ");
if (isOpeningFence || activeFenceCharacter || isIndentedCode) {
flush();
} else {
if (currentText.length === 0) {
currentStartOffset = offset;
}
currentText += lineWithBreak;
}
if (isOpeningFence) {
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
activeFenceLength = fenceDelimiter.length;
} else if (isClosingFence) {
activeFenceCharacter = null;
activeFenceLength = 0;
}
offset += lineWithBreak.length;
}
flush();
return segments;
}
function getAgentStreamItemSearchableSegments(item: StreamItem): AgentStreamSearchTextSegment[] {
switch (item.kind) {
case "user_message":
case "assistant_message":
return item.text ? getMessageSearchableSegments(item.text) : [];
case "thought":
case "activity_log":
case "todo_list":
case "tool_call":
case "compaction":
return [];
}
}
function mergeOptimisticItems(input: {
streamItems: StreamItem[];
optimisticItems: StreamItem[] | undefined;
}): StreamItem[] {
if (!input.optimisticItems || input.optimisticItems.length === 0) {
return input.streamItems;
}
const committedIds = new Set(input.streamItems.map((item) => item.id));
const pendingOptimisticItems = input.optimisticItems.filter((item) => !committedIds.has(item.id));
if (pendingOptimisticItems.length === 0) {
return input.streamItems;
}
return [...pendingOptimisticItems, ...input.streamItems];
}
function buildEntries(input: {
items: StreamItem[];
source: AgentStreamSearchSource;
startIndex: number;
cwd: string | undefined;
}): AgentStreamSearchEntry[] {
return input.items.map((item, offset) => {
const segments = getAgentStreamItemSearchableSegments(item);
return {
item,
source: input.source,
index: input.startIndex + offset,
text: segments.map((segment) => segment.text).join("\n"),
segments,
};
});
}
function orderStreamItems(input: {
items: StreamItem[];
platform: "web" | "native";
}): StreamItem[] {
return input.platform === "native" ? [...input.items].toReversed() : input.items;
}
function splitOrderedHistory(input: {
orderedTail: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
}): {
historyVirtualizedItems: StreamItem[];
historyMountedItems: StreamItem[];
} {
const shouldSplitHistory =
input.platform === "web" &&
!input.isMobileBreakpoint &&
input.orderedTail.length > getWebPartialVirtualizationThreshold();
if (!shouldSplitHistory) {
return {
historyVirtualizedItems: [],
historyMountedItems: input.orderedTail,
};
}
const mountedWindowStart = findMountedWindowStart({
items: input.orderedTail,
minMountedCount: getWebMountedRecentStreamItems(),
});
return {
historyVirtualizedItems: input.orderedTail.slice(0, mountedWindowStart),
historyMountedItems: input.orderedTail.slice(mountedWindowStart),
};
}
export function buildAgentStreamSearchModel(
input: BuildAgentStreamSearchModelInput,
): AgentStreamSearchModel {
const tail = mergeOptimisticItems({
streamItems: input.streamItems,
optimisticItems: input.optimisticItems,
});
const orderedTail = orderStreamItems({
items: tail,
platform: input.platform,
});
const orderedHead = orderStreamItems({
items: input.streamHead,
platform: input.platform,
});
const splitHistory = splitOrderedHistory({
orderedTail,
platform: input.platform,
isMobileBreakpoint: input.isMobileBreakpoint,
});
const historyVirtualized = buildEntries({
items: splitHistory.historyVirtualizedItems,
source: "historyVirtualized",
startIndex: 0,
cwd: input.cwd,
});
const historyMounted = buildEntries({
items: splitHistory.historyMountedItems,
source: "historyMounted",
startIndex: historyVirtualized.length,
cwd: input.cwd,
});
const liveHead = buildEntries({
items: orderedHead,
source: "liveHead",
startIndex: historyVirtualized.length + historyMounted.length,
cwd: input.cwd,
});
return {
entries: [...historyVirtualized, ...historyMounted, ...liveHead],
segments: {
historyVirtualized,
historyMounted,
liveHead,
},
};
}
export function findAgentStreamSearchMatches(
input: FindAgentStreamSearchMatchesInput,
): AgentStreamSearchMatch[] {
const normalizedQuery = input.query.toLocaleLowerCase();
if (!normalizedQuery) {
return [];
}
const matches: AgentStreamSearchMatch[] = [];
for (const entry of input.model.entries) {
for (const segment of entry.segments) {
const normalizedText = segment.text.toLocaleLowerCase();
let occurrenceIndex = 0;
let fromIndex = 0;
while (fromIndex <= normalizedText.length) {
const start = normalizedText.indexOf(normalizedQuery, fromIndex);
if (start < 0) {
break;
}
const end = start + input.query.length;
const absoluteStart = segment.startOffset + start;
const absoluteEnd = segment.startOffset + end;
matches.push({
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${absoluteStart}:${absoluteEnd}`,
entry,
segmentKey: segment.key,
occurrenceIndex,
start: absoluteStart,
end: absoluteEnd,
});
occurrenceIndex += 1;
fromIndex = end;
}
}
}
return matches;
}
@@ -0,0 +1,492 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserPane } from "@/components/browser-pane.electron";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
type PaneContextValue,
} from "@/panels/pane-context";
import {
createPaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
const updateBrowser = vi.fn();
let browserState = {
browsersById: {
"browser-a": {
id: "browser-a",
url: "https://example.com",
title: "",
faviconUrl: null,
canGoBack: false,
canGoForward: false,
isLoading: false,
lastError: null,
},
},
updateBrowser,
};
const { theme } = vi.hoisted(() => ({
theme: {
borderRadius: { md: 6 },
colors: {
accent: "#3b82f6",
border: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
palette: { red: { 500: "#ef4444" } },
surface0: "#111",
surface1: "#222",
surface2: "#333",
},
fontSize: { sm: 13, xs: 11 },
spacing: { 1: 4, 2: 8 },
},
}));
const desktopBridge = vi.hoisted(() => {
const foundInPageListeners = new Set<(result: unknown) => void>();
return {
foundInPageListeners,
findInPage: vi.fn<(browserId: string, text: string, options?: unknown) => number>(),
setActivePane: vi.fn<(browserId: string | null) => Promise<void>>(),
stopFindInPage: vi.fn<(browserId: string, action: string) => void>(),
onFoundInPage: vi.fn((_browserId: string, listener: (result: unknown) => void) => {
const scopedListener = (result: unknown) => listener(result);
foundInPageListeners.add(scopedListener);
return () => {
foundInPageListeners.delete(scopedListener);
};
}),
eventsOn: vi.fn(),
};
});
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon() {
return React.createElement("span", { "data-icon": name });
};
return {
ArrowLeft: createIcon("ArrowLeft"),
ArrowRight: createIcon("ArrowRight"),
ChevronDown: createIcon("ChevronDown"),
ChevronUp: createIcon("ChevronUp"),
MousePointer2: createIcon("MousePointer2"),
RotateCw: createIcon("RotateCw"),
X: createIcon("X"),
};
});
vi.mock("react-native", () => {
const MockView = ({
children,
testID,
style,
}: {
children?: React.ReactNode;
testID?: string;
style?: unknown;
}) => React.createElement("div", { "data-testid": testID, style }, children);
const MockText = ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children);
const MockPressable = ({
children,
disabled,
onPress,
testID,
}: {
children?: React.ReactNode;
disabled?: boolean;
onPress?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"data-testid": testID,
disabled,
onClick: () => {
if (!disabled) onPress?.();
},
type: "button",
},
children,
);
const MockTextInput = React.forwardRef<
HTMLInputElement,
{
value?: string;
onChangeText?: (text: string) => void;
onFocus?: () => void;
onKeyPress?: (event: {
nativeEvent: { key: string; shiftKey?: boolean };
preventDefault: () => void;
}) => void;
onSubmitEditing?: () => void;
testID?: string;
placeholder?: string;
}
>(function TextInput(
{ value, onChangeText, onFocus, onKeyPress, onSubmitEditing, testID, placeholder },
ref,
) {
return React.createElement("input", {
"data-testid": testID,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onFocus,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyPress?.({
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
preventDefault: () => event.preventDefault(),
});
if (event.key === "Enter") {
onSubmitEditing?.();
}
},
placeholder,
ref,
value: value ?? "",
});
});
return {
Platform: {
OS: "web",
select: (options: Record<string, unknown>) => options.web ?? options.default,
},
Pressable: MockPressable,
Text: MockText,
TextInput: MockTextInput,
View: MockView,
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
hairlineWidth: 1,
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/attachments/workspace-attachments-store", () => ({
buildWorkspaceAttachmentScopeKey: () => "scope-a",
useWorkspaceAttachments: () => [],
useWorkspaceAttachmentsStore: (
selector: (state: { setWorkspaceAttachments: () => void }) => unknown,
) => selector({ setWorkspaceAttachments: vi.fn() }),
}));
vi.mock("@/desktop/host", () => ({
getDesktopHost: () => ({
browser: {
findInPage: desktopBridge.findInPage,
onFoundInPage: desktopBridge.onFoundInPage,
setActivePane: desktopBridge.setActivePane,
stopFindInPage: desktopBridge.stopFindInPage,
},
events: { on: desktopBridge.eventsOn },
}),
isElectronRuntime: () => true,
}));
vi.mock("@/constants/layout", () => ({
WORKSPACE_SECONDARY_HEADER_HEIGHT: 36,
}));
vi.mock("@/stores/browser-store", () => ({
normalizeWorkspaceBrowserUrl: (url: string) => url,
useBrowserStore: (selector: (state: typeof browserState) => unknown) => selector(browserState),
}));
type FakeWebview = HTMLDivElement & {
getURL: ReturnType<typeof vi.fn<() => string>>;
canGoBack: ReturnType<typeof vi.fn<() => boolean>>;
canGoForward: ReturnType<typeof vi.fn<() => boolean>>;
reload: ReturnType<typeof vi.fn<() => void>>;
stop: ReturnType<typeof vi.fn<() => void>>;
};
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let webview: FakeWebview | null = null;
let nextRequestId = 1;
let restoreCreateElement: (() => void) | null = null;
const paneInstanceId = createPaneFindPaneId({
serverId: "server-a",
workspaceId: "workspace-a",
paneId: "pane-a",
});
const paneContextValue: PaneContextValue = {
serverId: "server-a",
workspaceId: "workspace-a",
paneInstanceId,
tabId: "browser_browser-a",
target: { kind: "browser", browserId: "browser-a" },
openTab: vi.fn(),
closeCurrentTab: vi.fn(),
retargetCurrentTab: vi.fn(),
openFileInWorkspace: vi.fn(),
openImportSheet: vi.fn(),
};
function installWebviewElementFactory(): void {
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi.spyOn(document, "createElement");
const createElement = ((tagName: string, options?: ElementCreationOptions) => {
if (tagName.toLowerCase() !== "webview") {
return originalCreateElement(tagName, options);
}
const element = originalCreateElement("div") as FakeWebview;
element.getURL = vi.fn(() => "https://example.com");
element.canGoBack = vi.fn(() => false);
element.canGoForward = vi.fn(() => false);
element.reload = vi.fn();
element.stop = vi.fn();
webview = element;
return element;
}) as typeof document.createElement;
createElementSpy.mockImplementation(createElement);
restoreCreateElement = () => createElementSpy.mockRestore();
}
function renderBrowserPane(input?: { isInteractive?: boolean }): void {
act(() => {
root?.render(
<PaneProvider value={paneContextValue}>
<PaneFocusProvider
value={createPaneFocusContextValue({
isPaneFocused: input?.isInteractive ?? true,
isWorkspaceFocused: true,
})}
>
<BrowserPane
browserId="browser-a"
serverId="server-a"
workspaceId="workspace-a"
cwd="/repo"
isInteractive={input?.isInteractive ?? true}
/>
</PaneFocusProvider>
</PaneProvider>,
);
});
}
function openFind(): void {
act(() => {
setActivePaneFindPaneId(paneInstanceId);
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
}
function markWebviewDomReady(): void {
act(() => {
webview?.dispatchEvent(new Event("dom-ready"));
});
}
function inputElement(): HTMLInputElement {
const input = container?.querySelector('[data-testid="pane-find-input"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
function changeInput(value: string): void {
const input = inputElement();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
}
function pressKey(key: string, shiftKey = false): void {
const input = inputElement();
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
});
}
function click(testId: string): void {
const element = container?.querySelector(`[data-testid="${testId}"]`);
expect(element).toBeInstanceOf(HTMLElement);
act(() => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function dispatchFoundInPage(result: {
requestId?: number;
activeMatchOrdinal: number;
matches: number;
}): void {
act(() => {
for (const listener of desktopBridge.foundInPageListeners) {
listener(result);
}
});
}
beforeEach(() => {
updateBrowser.mockClear();
desktopBridge.findInPage.mockImplementation(() => nextRequestId++);
desktopBridge.findInPage.mockClear();
desktopBridge.onFoundInPage.mockClear();
desktopBridge.setActivePane.mockClear();
desktopBridge.stopFindInPage.mockClear();
desktopBridge.eventsOn.mockClear();
desktopBridge.foundInPageListeners.clear();
browserState = {
...browserState,
browsersById: {
"browser-a": {
id: "browser-a",
url: "https://example.com",
title: "",
faviconUrl: null,
canGoBack: false,
canGoForward: false,
isLoading: false,
lastError: null,
},
},
};
nextRequestId = 1;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
installWebviewElementFactory();
});
afterEach(() => {
act(() => {
root?.unmount();
});
root = null;
container?.remove();
container = null;
webview = null;
restoreCreateElement?.();
restoreCreateElement = null;
});
describe("BrowserPane Electron find", () => {
it("registers browser find through the shared FindBar and desktop bridge find APIs", () => {
renderBrowserPane();
markWebviewDomReady();
openFind();
changeInput("needle");
expect(desktopBridge.onFoundInPage).toHaveBeenCalledWith("browser-a", expect.any(Function));
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: false,
matchCase: false,
});
expect(container?.textContent).toContain("Searching...");
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 2, matches: 5 });
expect(container?.textContent).toContain("2 / 5");
pressKey("Enter");
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: true,
matchCase: false,
});
dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 3, matches: 5 });
pressKey("Enter", true);
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: false,
findNext: true,
matchCase: false,
});
click("pane-find-close");
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("cleans browser find selection on empty query, navigation, blur, and unmount", () => {
renderBrowserPane();
markWebviewDomReady();
openFind();
changeInput("needle");
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 3 });
changeInput("");
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(container?.textContent).toContain("0 / 0");
dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 });
expect(container?.textContent).toContain("0 / 0");
changeInput("needle");
expect(container?.textContent).toContain("Searching...");
act(() => {
webview?.dispatchEvent(new Event("did-start-loading"));
});
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(container?.textContent).toContain("0 / 0");
act(() => {
webview?.dispatchEvent(new Event("dom-ready"));
});
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: false,
matchCase: false,
});
renderBrowserPane({ isInteractive: false });
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
act(() => {
root?.unmount();
});
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(desktopBridge.foundInPageListeners.size).toBe(0);
});
it("does not call the browser find bridge before dom-ready", () => {
renderBrowserPane();
openFind();
changeInput("needle");
expect(container?.textContent).toContain("Searching...");
expect(desktopBridge.findInPage).not.toHaveBeenCalled();
click("pane-find-close");
expect(desktopBridge.stopFindInPage).not.toHaveBeenCalled();
openFind();
changeInput("needle");
markWebviewDomReady();
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: false,
matchCase: false,
});
});
});
@@ -1,4 +1,4 @@
import {
import React, {
useCallback,
useEffect,
useMemo,
@@ -20,9 +20,12 @@ import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
getDesktopHost,
isElectronRuntime,
type DesktopBrowserFoundInPageResult,
type DesktopBrowserFindAction,
type DesktopBrowserShortcutEvent,
} from "@/desktop/host";
import { isDev } from "@/constants/platform";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
type ElectronWebview = HTMLElement & {
@@ -50,6 +53,15 @@ type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted"> & {
const ERR_ABORTED = -3;
const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]);
const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" };
const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" };
const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" };
interface ActiveBrowserFind {
generation: number;
query: string;
requestId: number | null;
}
function truncateText(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
@@ -234,6 +246,18 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow
return event.action === "focus-url";
}
function stopBrowserFindInPage(input: {
browserId: string;
action: DesktopBrowserFindAction;
}): void {
const bridge = getDesktopHost()?.browser;
if (!bridge?.stopFindInPage) {
console.warn("Electron browser find bridge is unavailable; cannot stop find-in-page.");
return;
}
void bridge.stopFindInPage(input.browserId, input.action);
}
function startSelectorResultPolling(input: {
webview: ElectronWebview;
onSelection: (selection: BrowserElementSelection) => void;
@@ -295,7 +319,13 @@ export function BrowserPane({
browserRef.current = browser;
const pendingNavigationUrlRef = useRef<string | null>(null);
const domReadyRef = useRef(false);
const browserFindQueryRef = useRef("");
const browserFindGenerationRef = useRef(0);
const activeBrowserFindRef = useRef<ActiveBrowserFind | null>(null);
const browserFindOpenRef = useRef(false);
const [selectorActive, setSelectorActive] = useState(false);
const [browserFindMatchState, setBrowserFindMatchState] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com");
const workspaceAttachmentScopeKey = useMemo(
() => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }),
@@ -372,6 +402,115 @@ export function BrowserPane({
}
}, []);
const clearBrowserFindSelection = useCallback((input?: { resetQuery?: boolean }) => {
if (input?.resetQuery) {
browserFindQueryRef.current = "";
}
browserFindGenerationRef.current += 1;
activeBrowserFindRef.current = null;
setBrowserFindMatchState(EMPTY_FIND_MATCH_STATE);
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
action: "clearSelection",
});
}
}, []);
const runBrowserFind = useCallback(
(query: string, direction: "next" | "previous", input?: { reset?: boolean }) => {
browserFindQueryRef.current = query;
if (!query) {
clearBrowserFindSelection({ resetQuery: true });
return;
}
if (!domReadyRef.current) {
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
return;
}
const bridgeFindInPage = getDesktopHost()?.browser?.findInPage;
if (!bridgeFindInPage) {
console.warn("Electron browser find bridge is unavailable; cannot start find-in-page.");
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
return;
}
const generation = (browserFindGenerationRef.current += 1);
activeBrowserFindRef.current = {
generation,
query,
requestId: null,
};
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
const options = {
forward: direction === "next",
findNext: !input?.reset,
matchCase: false,
};
const requestIdResult = bridgeFindInPage(browserIdRef.current, query, options);
if (typeof requestIdResult === "number") {
activeBrowserFindRef.current = {
generation,
query,
requestId: requestIdResult,
};
return;
}
void Promise.resolve(requestIdResult ?? null).then((requestId) => {
const activeFind = activeBrowserFindRef.current;
if (
!activeFind ||
activeFind.generation !== generation ||
activeFind.query !== browserFindQueryRef.current
) {
return undefined;
}
activeBrowserFindRef.current = {
...activeFind,
requestId: typeof requestId === "number" ? requestId : null,
};
return undefined;
});
},
[clearBrowserFindSelection],
);
const handleFoundInPageResult = useCallback((result: DesktopBrowserFoundInPageResult) => {
const activeFind = activeBrowserFindRef.current;
if (
!activeFind ||
activeFind.generation !== browserFindGenerationRef.current ||
activeFind.query !== browserFindQueryRef.current
) {
return;
}
const requestId = result.requestId;
if (typeof requestId !== "number") {
return;
}
if (typeof activeFind.requestId !== "number" || requestId !== activeFind.requestId) {
return;
}
const total = typeof result.matches === "number" ? Math.max(0, result.matches) : 0;
if (total === 0) {
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
return;
}
const current =
typeof result.activeMatchOrdinal === "number" && result.activeMatchOrdinal > 0
? result.activeMatchOrdinal
: 1;
setBrowserFindMatchState({
status: "matched",
current: Math.min(current, total),
total,
});
}, []);
useEffect(() => {
if (!isElectronRuntime()) {
return;
@@ -403,6 +542,7 @@ export function BrowserPane({
webview.style.background = "transparent";
const handleStartLoading = () => {
clearBrowserFindSelection();
updateBrowser(browserId, { isLoading: true, lastError: null });
syncNavigationState({ syncUrl: false });
};
@@ -429,6 +569,7 @@ export function BrowserPane({
syncNavigationState();
};
const handleWillNavigate = (event: Event) => {
clearBrowserFindSelection();
const nextUrl =
typeof (event as Event & { url?: unknown }).url === "string"
? ((event as Event & { url?: string }).url ?? "")
@@ -471,6 +612,9 @@ export function BrowserPane({
const handleDomReady = () => {
domReadyRef.current = true;
syncNavigationState();
if (browserFindOpenRef.current && browserFindQueryRef.current) {
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
}
};
const handleWebviewFocus = () => {
onFocusPane?.();
@@ -488,6 +632,29 @@ export function BrowserPane({
webview.addEventListener("focus", handleWebviewFocus);
webview.addEventListener("mousedown", handleWebviewFocus);
const foundInPageBridge = getDesktopHost()?.browser?.onFoundInPage;
let unsubscribeFoundInPage: (() => void) | null = null;
let didCleanupFoundInPage = false;
if (foundInPageBridge) {
const unsubscribeResult = foundInPageBridge(browserId, (result) => {
handleFoundInPageResult(result);
});
if (typeof unsubscribeResult === "function") {
unsubscribeFoundInPage = unsubscribeResult;
} else {
void Promise.resolve(unsubscribeResult).then((unsubscribe) => {
if (didCleanupFoundInPage) {
unsubscribe();
return undefined;
}
unsubscribeFoundInPage = unsubscribe;
return undefined;
});
}
} else {
console.warn("Electron browser find bridge is unavailable; found-in-page events disabled.");
}
host.appendChild(webview);
if (initialUnsafeNavigationMessage) {
updateBrowserRef.current(browserIdRef.current, {
@@ -506,8 +673,16 @@ export function BrowserPane({
webview.removeEventListener("page-favicon-updated", handleFaviconUpdated);
webview.removeEventListener("did-fail-load", handleLoadFailed);
webview.removeEventListener("dom-ready", handleDomReady);
didCleanupFoundInPage = true;
unsubscribeFoundInPage?.();
webview.removeEventListener("focus", handleWebviewFocus);
webview.removeEventListener("mousedown", handleWebviewFocus);
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
action: "clearSelection",
});
}
if (host.contains(webview)) {
host.removeChild(webview);
}
@@ -519,6 +694,35 @@ export function BrowserPane({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserId, onFocusPane]);
const paneFind = usePaneFind({
matchState: browserFindMatchState,
onQuery: (query) => runBrowserFind(query, "next", { reset: true }),
onNext: () => runBrowserFind(browserFindQueryRef.current, "next"),
onPrev: () => runBrowserFind(browserFindQueryRef.current, "previous"),
onClose: () => clearBrowserFindSelection({ resetQuery: true }),
});
useEffect(() => {
browserFindOpenRef.current = paneFind.isOpen;
if (!paneFind.isOpen) {
clearBrowserFindSelection({ resetQuery: true });
}
}, [clearBrowserFindSelection, paneFind.isOpen]);
useEffect(() => {
if (isInteractive) {
if (paneFind.isOpen && browserFindQueryRef.current) {
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
}
return;
}
clearBrowserFindSelection();
}, [clearBrowserFindSelection, isInteractive, paneFind.isOpen, runBrowserFind]);
useEffect(() => {
return () => clearBrowserFindSelection({ resetQuery: true });
}, [clearBrowserFindSelection]);
const navigate = useCallback((nextUrl: string) => {
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current;
@@ -939,6 +1143,7 @@ export function BrowserPane({
return (
<View style={styles.container}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
<Pressable
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
createFilePaneFindTokenSegments,
createFilePaneLineFindHighlightMap,
createFilePaneTextRenderData,
findFilePaneTextMatches,
} from "@/components/file-pane-text-render-data";
type FilePaneTextLineRenderData = ReturnType<typeof createFilePaneTextRenderData>["lines"][number];
function tokenText(line: FilePaneTextLineRenderData): string {
return line.tokens.map(({ text }) => text).join("");
}
describe("createFilePaneTextRenderData", () => {
it("keeps code render lines in source order and reconstructs searchable text from tokens", () => {
const renderData = createFilePaneTextRenderData(
"const answer = 42;\nconsole.log(answer);",
"src/answer.ts",
);
expect(renderData.lines.map((line) => line.lineNumber)).toEqual([1, 2]);
expect(renderData.lines.map((line) => line.text)).toEqual([
"const answer = 42;",
"console.log(answer);",
]);
expect(renderData.searchableText).toBe("const answer = 42;\nconsole.log(answer);");
const tokenTexts = renderData.lines.map(tokenText);
expect(tokenTexts).toEqual(renderData.lines.map((line) => line.text));
});
it("preserves blank text lines for navigation and scrolling targets", () => {
const renderData = createFilePaneTextRenderData("alpha\n\nbeta", "notes.txt");
expect(
renderData.lines.map((line) => ({ lineNumber: line.lineNumber, text: line.text })),
).toEqual([
{ lineNumber: 1, text: "alpha" },
{ lineNumber: 2, text: "" },
{ lineNumber: 3, text: "beta" },
]);
expect(renderData.searchableText).toBe("alpha\n\nbeta");
});
});
describe("findFilePaneTextMatches", () => {
it("indexes loaded text case-insensitively in source order", () => {
const renderData = createFilePaneTextRenderData(
"Alpha beta\nBETA gamma\nalphabet",
"notes.txt",
);
const matches = findFilePaneTextMatches(renderData, "beta");
expect(
matches.map((match) => ({
index: match.index,
lineSpans: match.lineSpans,
})),
).toEqual([
{
index: 0,
lineSpans: [{ lineNumber: 1, startColumn: 6, endColumn: 10 }],
},
{
index: 1,
lineSpans: [{ lineNumber: 2, startColumn: 0, endColumn: 4 }],
},
]);
});
it("maps pasted multi-line queries back to per-line spans", () => {
const renderData = createFilePaneTextRenderData("alpha\nbeta", "notes.txt");
const matches = findFilePaneTextMatches(renderData, "ha\nbe");
expect(matches).toHaveLength(1);
expect(matches[0]?.lineSpans).toEqual([
{ lineNumber: 1, startColumn: 3, endColumn: 5 },
{ lineNumber: 2, startColumn: 0, endColumn: 2 },
]);
});
});
describe("createFilePaneFindTokenSegments", () => {
it("splits immutable token render data around match spans and marks the current match", () => {
const line: FilePaneTextLineRenderData = {
lineNumber: 1,
text: "const answer",
tokens: [
{ text: "const", style: "keyword" },
{ text: " answer", style: null },
],
};
const highlights = createFilePaneLineFindHighlightMap(
[
{
index: 0,
startOffset: 2,
endOffset: 8,
lineSpans: [{ lineNumber: 1, startColumn: 2, endColumn: 8 }],
},
],
0,
);
const segments = createFilePaneFindTokenSegments(line, highlights.get(1) ?? []);
expect(segments).toEqual([
{ text: "co", style: "keyword", isFindMatch: false, isCurrentFindMatch: false },
{ text: "nst", style: "keyword", isFindMatch: true, isCurrentFindMatch: true },
{ text: " an", style: null, isFindMatch: true, isCurrentFindMatch: true },
{ text: "swer", style: null, isFindMatch: false, isCurrentFindMatch: false },
]);
expect(line.tokens).toEqual([
{ text: "const", style: "keyword" },
{ text: " answer", style: null },
]);
});
});
@@ -0,0 +1,198 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
interface FilePaneTextLineRenderData {
lineNumber: number;
text: string;
tokens: HighlightToken[];
}
interface FilePaneTextRenderData {
lines: FilePaneTextLineRenderData[];
searchableText: string;
}
interface FilePaneFindLineSpan {
lineNumber: number;
startColumn: number;
endColumn: number;
}
export interface FilePaneFindMatch {
index: number;
startOffset: number;
endOffset: number;
lineSpans: FilePaneFindLineSpan[];
}
export interface FilePaneFindLineHighlight extends FilePaneFindLineSpan {
matchIndex: number;
isCurrent: boolean;
}
export interface FilePaneFindTokenSegment {
text: string;
style: HighlightToken["style"];
isFindMatch: boolean;
isCurrentFindMatch: boolean;
}
export function createFilePaneTextRenderData(
content: string,
filePath: string,
): FilePaneTextRenderData {
const lines = highlightCode(content, filePath).map((tokens, index) => ({
lineNumber: index + 1,
text: tokens.map((token) => token.text).join(""),
tokens,
}));
return {
lines,
searchableText: lines.map((line) => line.text).join("\n"),
};
}
export function findFilePaneTextMatches(
renderData: FilePaneTextRenderData,
query: string,
): FilePaneFindMatch[] {
if (query.length === 0) {
return [];
}
const searchableText = renderData.searchableText.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matches: FilePaneFindMatch[] = [];
let nextOffset = searchableText.indexOf(normalizedQuery);
while (nextOffset >= 0) {
const endOffset = nextOffset + normalizedQuery.length;
matches.push({
index: matches.length,
startOffset: nextOffset,
endOffset,
lineSpans: mapFilePaneTextRangeToLineSpans(renderData, nextOffset, endOffset),
});
nextOffset = searchableText.indexOf(normalizedQuery, endOffset);
}
return matches;
}
export function createFilePaneLineFindHighlightMap(
matches: FilePaneFindMatch[],
currentMatchIndex: number,
): Map<number, FilePaneFindLineHighlight[]> {
const highlightsByLine = new Map<number, FilePaneFindLineHighlight[]>();
for (const match of matches) {
for (const span of match.lineSpans) {
const highlights = highlightsByLine.get(span.lineNumber) ?? [];
highlights.push({
...span,
matchIndex: match.index,
isCurrent: match.index === currentMatchIndex,
});
highlightsByLine.set(span.lineNumber, highlights);
}
}
for (const highlights of highlightsByLine.values()) {
highlights.sort((left, right) => left.startColumn - right.startColumn);
}
return highlightsByLine;
}
export function createFilePaneFindTokenSegments(
line: FilePaneTextLineRenderData,
highlights: FilePaneFindLineHighlight[],
): FilePaneFindTokenSegment[] {
if (highlights.length === 0) {
return line.tokens.map((token) => ({
...token,
isFindMatch: false,
isCurrentFindMatch: false,
}));
}
const segments: FilePaneFindTokenSegment[] = [];
let tokenStartColumn = 0;
for (const token of line.tokens) {
let cursor = tokenStartColumn;
const tokenEndColumn = tokenStartColumn + token.text.length;
const tokenHighlights = highlights.filter(
(highlight) =>
highlight.startColumn < tokenEndColumn && highlight.endColumn > tokenStartColumn,
);
for (const highlight of tokenHighlights) {
const highlightStart = Math.max(highlight.startColumn, tokenStartColumn);
const highlightEnd = Math.min(highlight.endColumn, tokenEndColumn);
if (cursor < highlightStart) {
segments.push({
text: token.text.slice(cursor - tokenStartColumn, highlightStart - tokenStartColumn),
style: token.style,
isFindMatch: false,
isCurrentFindMatch: false,
});
}
if (highlightStart < highlightEnd) {
segments.push({
text: token.text.slice(
highlightStart - tokenStartColumn,
highlightEnd - tokenStartColumn,
),
style: token.style,
isFindMatch: true,
isCurrentFindMatch: highlight.isCurrent,
});
}
cursor = highlightEnd;
}
if (cursor < tokenEndColumn) {
segments.push({
text: token.text.slice(cursor - tokenStartColumn),
style: token.style,
isFindMatch: false,
isCurrentFindMatch: false,
});
}
tokenStartColumn = tokenEndColumn;
}
return segments;
}
function mapFilePaneTextRangeToLineSpans(
renderData: FilePaneTextRenderData,
startOffset: number,
endOffset: number,
): FilePaneFindLineSpan[] {
const spans: FilePaneFindLineSpan[] = [];
let lineStartOffset = 0;
for (const line of renderData.lines) {
const lineEndOffset = lineStartOffset + line.text.length;
const spanStartOffset = Math.max(startOffset, lineStartOffset);
const spanEndOffset = Math.min(endOffset, lineEndOffset);
if (spanStartOffset < spanEndOffset) {
spans.push({
lineNumber: line.lineNumber,
startColumn: spanStartOffset - lineStartOffset,
endColumn: spanEndOffset - lineStartOffset,
});
}
lineStartOffset = lineEndOffset + 1;
}
return spans;
}
@@ -0,0 +1,435 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExplorerFile } from "@/stores/session-store";
import { FilePane } from "@/components/file-pane";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
type PaneContextValue,
} from "@/panels/pane-context";
import {
clearActivePaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
const { queryState, theme } = vi.hoisted(() => ({
queryState: {
current: {
data: null as null | {
error: string | null;
file: ExplorerFile | null;
imageAttachment?: unknown;
},
isFetching: false,
},
},
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
fontSize: { sm: 13, code: 13 },
colors: {
destructive: "#f43f5e",
foreground: "#fff",
foregroundMuted: "#aaa",
primary: "#0a84ff",
surface0: "#111",
surface1: "#222",
surface2: "#333",
},
colorScheme: "dark",
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryState.current,
}));
vi.mock("react-native", () => {
const MockView = React.forwardRef<
HTMLDivElement,
{ children?: React.ReactNode; testID?: string }
>(function View({ children, testID }, ref) {
return React.createElement("div", { "data-testid": testID, ref }, children);
});
const MockScrollView = React.forwardRef<
HTMLDivElement,
{ children?: React.ReactNode; horizontal?: boolean }
>(function ScrollView({ children, horizontal }, ref) {
return React.createElement(
"div",
{ "data-horizontal": horizontal ? "true" : undefined, ref },
children,
);
});
const flattenMockStyle = (value: unknown): React.CSSProperties => {
if (Array.isArray(value)) {
return value.reduce<React.CSSProperties>(
(acc, item) => Object.assign(acc, flattenMockStyle(item)),
{},
);
}
return (value as React.CSSProperties | null | undefined) ?? {};
};
const MockText = ({ children, style }: { children?: React.ReactNode; style?: unknown }) =>
React.createElement("span", { style: flattenMockStyle(style) }, children);
const MockPressable = ({
children,
disabled,
onPress,
testID,
}: {
children?: React.ReactNode;
disabled?: boolean;
onPress?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"data-testid": testID,
disabled,
onClick: () => {
if (!disabled) {
onPress?.();
}
},
type: "button",
},
children,
);
const MockTextInput = React.forwardRef<
HTMLInputElement,
{
value?: string;
onChangeText?: (text: string) => void;
onKeyPress?: (event: {
nativeEvent: { key: string; shiftKey?: boolean };
preventDefault: () => void;
}) => void;
testID?: string;
placeholder?: string;
}
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
return React.createElement("input", {
"data-testid": testID,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
onKeyPress?.({
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
preventDefault: () => event.preventDefault(),
}),
placeholder,
ref,
value: value ?? "",
});
});
const MockImage = ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { alt: "", src: source?.uri ?? "" });
return {
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
Image: MockImage,
Platform: {
OS: "web",
select: (options: Record<string, unknown>) => options.web ?? options.default,
},
Pressable: MockPressable,
ScrollView: MockScrollView,
Text: MockText,
TextInput: MockTextInput,
View: MockView,
};
});
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon() {
return React.createElement("span", { "data-icon": name });
};
return {
ChevronDown: createIcon("ChevronDown"),
ChevronUp: createIcon("ChevronUp"),
X: createIcon("X"),
};
});
vi.mock("react-native-markdown-display", () => ({
default: ({ children }: { children?: React.ReactNode }) =>
React.createElement("article", { "data-testid": "markdown-preview" }, children),
MarkdownIt: () => ({}),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/styles/syntax-token-styles", () => ({
syntaxTokenStyleFor: () => ({ color: "#fff" }),
}));
vi.mock("@/attachments/use-attachment-preview-url", () => ({
useAttachmentPreviewUrl: (metadata: unknown) => (metadata ? "blob:preview" : null),
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebScrollViewScrollbar: () => ({
onContentSizeChange: vi.fn(),
onLayout: vi.fn(),
onScroll: vi.fn(),
overlay: null,
}),
}));
vi.mock("@/hooks/use-web-scrollbar-style", () => ({
useWebScrollbarStyle: () => ({}),
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
}));
vi.mock("@/styles/markdown-styles", () => ({
createMarkdownStyles: () => ({}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) => selector({ sessions: {} }),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
const paneInstanceId = "server:workspace:file";
const paneContext: PaneContextValue = {
serverId: "server",
workspaceId: "workspace",
paneInstanceId,
tabId: "file",
target: { kind: "file", path: "src/example.ts" },
openTab: () => {},
closeCurrentTab: () => {},
retargetCurrentTab: () => {},
openFileInWorkspace: () => {},
openImportSheet: () => {},
};
const paneFocus = createPaneFocusContextValue({
isPaneFocused: true,
isWorkspaceFocused: true,
});
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
HTMLElement.prototype.scrollIntoView = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
clearActivePaneFindPaneId(paneInstanceId);
root = null;
container?.remove();
container = null;
queryState.current = { data: null, isFetching: false };
vi.unstubAllGlobals();
});
function renderFilePane(location: { path: string } = { path: "src/example.ts" }) {
act(() => {
root?.render(
<PaneProvider value={paneContext}>
<PaneFocusProvider value={paneFocus}>
<FilePane serverId="server" workspaceRoot="/repo" location={location} />
</PaneFocusProvider>
</PaneProvider>,
);
});
setActivePaneFindPaneId(paneInstanceId);
}
function inputElement(): HTMLInputElement {
const input = container?.querySelector('[data-testid="pane-find-input"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
function changeInput(value: string): void {
const input = inputElement();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
}
function click(testId: string): void {
const element = container?.querySelector(`[data-testid="${testId}"]`);
expect(element).toBeInstanceOf(HTMLElement);
act(() => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function makeFile(file: Partial<ExplorerFile>): ExplorerFile {
return {
content: "",
encoding: "utf-8",
kind: "text",
modifiedAt: "2026-05-02T00:00:00.000Z",
path: file.path ?? "src/example.ts",
size: file.size ?? 0,
...file,
};
}
describe("FilePane preview rendering", () => {
it("renders code/text previews with line numbers and highlighted token text", () => {
queryState.current = {
data: {
error: null,
file: makeFile({ content: "const answer = 42;\nconsole.log(answer);" }),
},
isFetching: false,
};
renderFilePane();
const text = container?.textContent ?? "";
expect(text).toContain("1");
expect(text).toContain("2");
expect(text).toContain("const answer = 42;");
expect(text).toContain("console.log(answer);");
});
it("keeps markdown files on the markdown preview path", () => {
queryState.current = {
data: {
error: null,
file: makeFile({ content: "# Guide", path: "README.md" }),
},
isFetching: false,
};
renderFilePane({ path: "README.md" });
expect(container?.querySelector('[data-testid="markdown-preview"]')?.textContent).toBe(
"# Guide",
);
});
it("renders image previews from the attachment preview URL", () => {
queryState.current = {
data: {
error: null,
file: makeFile({ content: undefined, encoding: "none", kind: "image", path: "logo.png" }),
imageAttachment: { id: "preview" },
},
isFetching: false,
};
renderFilePane({ path: "logo.png" });
expect(container?.querySelector("img")?.getAttribute("src")).toBe("blob:preview");
});
it("keeps binary previews on the unavailable state with file size", () => {
queryState.current = {
data: {
error: null,
file: makeFile({
content: undefined,
encoding: "none",
kind: "binary",
path: "tool",
size: 2048,
}),
},
isFetching: false,
};
renderFilePane({ path: "tool" });
expect(container?.textContent).toContain("Binary preview unavailable");
expect(container?.textContent).toContain("2.0 KB");
});
it("searches code/text previews case-insensitively and navigates matches", () => {
queryState.current = {
data: {
error: null,
file: makeFile({ content: "const answer = 42;\nconsole.log(ANSWER);" }),
},
isFetching: false,
};
renderFilePane();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("answer");
expect(container?.textContent).toContain("1 / 2");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1);
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBeGreaterThan(
0,
);
click("pane-find-next");
expect(container?.textContent).toContain("2 / 2");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(2);
click("pane-find-prev");
expect(container?.textContent).toContain("1 / 2");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(3);
});
it("clears file highlights on empty query and close", () => {
queryState.current = {
data: {
error: null,
file: makeFile({ content: "needle\nneedle" }),
},
isFetching: false,
};
renderFilePane();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("needle");
expect(container?.textContent).toContain("1 / 2");
changeInput("");
expect(container?.textContent).toContain("0 / 0");
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0);
changeInput("needle");
click("pane-find-close");
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0);
});
});
+578 -151
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -15,7 +15,7 @@ import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import type { HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
@@ -26,15 +26,33 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import {
createFilePaneFindTokenSegments,
createFilePaneLineFindHighlightMap,
createFilePaneTextRenderData,
findFilePaneTextMatches,
type FilePaneFindLineHighlight,
type FilePaneFindMatch,
type FilePaneFindTokenSegment,
} from "@/components/file-pane-text-render-data";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
interface CodeLineProps {
tokens: HighlightToken[];
segments: FilePaneFindTokenSegment[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
onLineRef?: (lineNumber: number, node: View | null) => void;
}
interface FilePreviewBodyProps {
@@ -46,6 +64,50 @@ interface FilePreviewBodyProps {
imagePreviewUri: string | null;
}
interface FilePaneTextScrollRefs {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
registerLineRef: (lineNumber: number, node: View | null) => void;
}
interface FilePaneCenterStateProps {
children: React.ReactNode;
}
interface FilePaneTextPreviewProps {
currentMatchBackgroundColor: string;
findHighlightsByLine: Map<number, FilePaneFindLineHighlight[]>;
gutterWidth: number;
isMarkdownFile: boolean;
isMobile: boolean;
lineSelection: FileLineSelection | null;
markdownParser: ReturnType<typeof MarkdownIt>;
markdownStyles: ReturnType<typeof createMarkdownStyles>;
matchBackgroundColor: string;
preview: ExplorerFile;
previewScrollRef: React.RefObject<RNScrollView | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
showDesktopWebScrollbar: boolean;
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
textScrollRefs: FilePaneTextScrollRefs;
webScrollbarStyle: object;
}
interface FilePaneSearchableTextPreviewProps extends Omit<
FilePaneTextPreviewProps,
"findHighlightsByLine" | "textRenderData"
> {
textRenderData: ReturnType<typeof createFilePaneTextRenderData>;
}
interface FilePaneImagePreviewProps {
imagePreviewUri: string | null;
imageSource: { uri: string } | null;
previewScrollRef: React.RefObject<RNScrollView | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
showDesktopWebScrollbar: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -117,11 +179,20 @@ function clampLineSelection(input: {
}
const CodeLine = React.memo(function CodeLine({
tokens,
segments,
lineNumber,
gutterWidth,
highlighted,
matchBackgroundColor,
currentMatchBackgroundColor,
onLineRef,
}: CodeLineProps) {
const setLineRef = useCallback(
(node: View | null) => {
onLineRef?.(lineNumber, node);
},
[lineNumber, onLineRef],
);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
@@ -131,19 +202,28 @@ const CodeLine = React.memo(function CodeLine({
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
() => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })),
[segments],
);
return (
<View style={lineStyle}>
<View ref={setLineRef} style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
{keyedTokens.map(({ key, segment }) => (
<CodeLineToken
key={key}
backgroundColor={getFindSegmentBackgroundColor({
currentMatchBackgroundColor,
matchBackgroundColor,
segment,
})}
style={segment.style}
text={segment.text}
/>
))}
</Text>
</View>
@@ -151,11 +231,237 @@ const CodeLine = React.memo(function CodeLine({
});
interface CodeLineTokenProps {
token: HighlightToken;
backgroundColor?: string;
style: HighlightToken["style"];
text: string;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) {
const tokenStyle = useMemo(
() => [
style ? syntaxTokenStyleFor(style) : undefined,
backgroundColor ? { backgroundColor } : null,
],
[backgroundColor, style],
);
return <Text style={tokenStyle}>{text}</Text>;
}
function getFindSegmentBackgroundColor(input: {
segment: FilePaneFindTokenSegment;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
}) {
if (input.segment.isCurrentFindMatch) {
return input.currentMatchBackgroundColor;
}
if (input.segment.isFindMatch) {
return input.matchBackgroundColor;
}
return undefined;
}
function useFilePaneTextScrollRefs(lineNumbers: number[] | null): FilePaneTextScrollRefs {
const previewScrollRef = useRef<RNScrollView>(null);
const lineRefs = useRef(new Map<number, View>());
const registerLineRef = useCallback((lineNumber: number, node: View | null) => {
if (node) {
lineRefs.current.set(lineNumber, node);
return;
}
lineRefs.current.delete(lineNumber);
}, []);
useEffect(() => {
if (!lineNumbers) {
lineRefs.current.clear();
return;
}
const visibleLineNumbers = new Set(lineNumbers);
for (const lineNumber of lineRefs.current.keys()) {
if (!visibleLineNumbers.has(lineNumber)) {
lineRefs.current.delete(lineNumber);
}
}
}, [lineNumbers]);
return {
lineRefs,
previewScrollRef,
registerLineRef,
};
}
function createFilePaneMatchState(
query: string,
matches: FilePaneFindMatch[],
currentMatchIndex: number,
): PaneFindMatchState {
if (query.length === 0) {
return { status: "empty" };
}
if (matches.length === 0) {
return { status: "no-match" };
}
return {
status: "matched",
current: Math.max(0, currentMatchIndex) + 1,
total: matches.length,
};
}
function scrollFilePaneLineIntoView(input: {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
lineNumber: number;
}) {
const lineNode = input.lineRefs.current.get(input.lineNumber);
const scrollNode = input.previewScrollRef.current;
if (!lineNode || !scrollNode) {
return;
}
if (isWeb && "scrollIntoView" in lineNode) {
(
lineNode as unknown as { scrollIntoView(options?: ScrollIntoViewOptions): void }
).scrollIntoView({
block: "center",
inline: "nearest",
});
return;
}
const measurableLineNode = lineNode as View & {
measureLayout?: (
relativeToNativeNode: unknown,
onSuccess: (x: number, y: number) => void,
onFail?: () => void,
) => void;
};
measurableLineNode.measureLayout?.(scrollNode, (_x, y) => {
scrollNode.scrollTo({ y: Math.max(0, y - 48), animated: true });
});
}
function scrollFilePaneLineIntoViewSoon(input: {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
lineNumber: number;
}) {
const schedule =
globalThis.requestAnimationFrame ??
((callback: FrameRequestCallback) => {
setTimeout(() => callback(Date.now()), 0);
return 0;
});
schedule(() => {
scrollFilePaneLineIntoView(input);
});
}
interface FilePaneFindState {
query: string;
matches: FilePaneFindMatch[];
currentMatchIndex: number;
}
const EMPTY_FILE_PANE_FIND_STATE: FilePaneFindState = {
query: "",
matches: [],
currentMatchIndex: 0,
};
function useFilePaneFindAdapter(input: {
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
textScrollRefs: FilePaneTextScrollRefs;
}) {
const [findState, setFindState] = useState<FilePaneFindState>(EMPTY_FILE_PANE_FIND_STATE);
const findQuery = findState.query;
const findMatches = findState.matches;
const currentMatchIndex = findState.currentMatchIndex;
const findHighlightsByLine = useMemo(
() => createFilePaneLineFindHighlightMap(findMatches, currentMatchIndex),
[currentMatchIndex, findMatches],
);
const findMatchState = useMemo(
() => createFilePaneMatchState(findQuery, findMatches, currentMatchIndex),
[currentMatchIndex, findMatches, findQuery],
);
const scrollMatchIntoView = useCallback(
(matches: FilePaneFindMatch[], matchIndex: number) => {
const lineNumber = matches[matchIndex]?.lineSpans[0]?.lineNumber;
if (!lineNumber) {
return;
}
scrollFilePaneLineIntoViewSoon({
lineRefs: input.textScrollRefs.lineRefs,
lineNumber,
previewScrollRef: input.textScrollRefs.previewScrollRef,
});
},
[input.textScrollRefs.lineRefs, input.textScrollRefs.previewScrollRef],
);
const paneFind = usePaneFind({
matchState: findMatchState,
onQuery: (query) => {
const nextMatches = input.textRenderData
? findFilePaneTextMatches(input.textRenderData, query)
: [];
setFindState({ query, matches: nextMatches, currentMatchIndex: 0 });
scrollMatchIntoView(nextMatches, 0);
return createFilePaneMatchState(query, nextMatches, 0);
},
onNext: () => {
if (findMatches.length === 0) {
return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex);
}
const nextIndex = (currentMatchIndex + 1) % findMatches.length;
setFindState((current) => ({ ...current, currentMatchIndex: nextIndex }));
scrollMatchIntoView(findMatches, nextIndex);
return createFilePaneMatchState(findQuery, findMatches, nextIndex);
},
onPrev: () => {
if (findMatches.length === 0) {
return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex);
}
const nextIndex = (currentMatchIndex - 1 + findMatches.length) % findMatches.length;
setFindState((current) => ({ ...current, currentMatchIndex: nextIndex }));
scrollMatchIntoView(findMatches, nextIndex);
return createFilePaneMatchState(findQuery, findMatches, nextIndex);
},
onClose: () => {
setFindState(EMPTY_FILE_PANE_FIND_STATE);
},
});
useEffect(() => {
setFindState((current) => {
if (!input.textRenderData || current.query.length === 0) {
return current.query.length === 0 &&
current.matches.length === 0 &&
current.currentMatchIndex === 0
? current
: EMPTY_FILE_PANE_FIND_STATE;
}
const nextMatches = findFilePaneTextMatches(input.textRenderData, current.query);
const nextMatchIndex =
nextMatches.length === 0 ? 0 : Math.min(current.currentMatchIndex, nextMatches.length - 1);
return {
query: current.query,
matches: nextMatches,
currentMatchIndex: nextMatchIndex,
};
});
}, [input.textRenderData]);
return {
findHighlightsByLine,
paneFind,
};
}
const codeLineStyles = StyleSheet.create((theme) => ({
@@ -186,6 +492,175 @@ const codeLineStyles = StyleSheet.create((theme) => ({
},
}));
function FilePaneFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
function FilePaneCenterState({ children }: FilePaneCenterStateProps) {
return <View style={styles.centerState}>{children}</View>;
}
function isLineSelected(lineSelection: FileLineSelection | null, lineNumber: number): boolean {
if (!lineSelection) {
return false;
}
return lineNumber >= lineSelection.lineStart && lineNumber <= lineSelection.lineEnd;
}
function FilePaneTextPreview({
currentMatchBackgroundColor,
findHighlightsByLine,
gutterWidth,
isMarkdownFile,
isMobile,
lineSelection,
markdownParser,
markdownStyles,
matchBackgroundColor,
preview,
previewScrollRef,
scrollbar,
showDesktopWebScrollbar,
textRenderData,
textScrollRefs,
webScrollbarStyle,
}: FilePaneTextPreviewProps) {
if (isMarkdownFile) {
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</>
);
}
const lines = textRenderData?.lines ?? [
{
lineNumber: 1,
text: preview.content ?? "",
tokens: [{ text: preview.content ?? "", style: null }],
},
];
const keyedLines = lines.map((line) => ({
key: `line-${line.lineNumber}`,
line,
}));
const codeLines = (
<View>
{keyedLines.map(({ key, line }) => (
<CodeLine
key={key}
segments={createFilePaneFindTokenSegments(
line,
findHighlightsByLine.get(line.lineNumber) ?? [],
)}
lineNumber={line.lineNumber}
gutterWidth={gutterWidth}
highlighted={isLineSelected(lineSelection, line.lineNumber)}
matchBackgroundColor={matchBackgroundColor}
currentMatchBackgroundColor={currentMatchBackgroundColor}
onLineRef={textScrollRefs.registerLineRef}
/>
))}
</View>
);
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</>
);
}
function FilePaneSearchableTextPreview(props: FilePaneSearchableTextPreviewProps) {
const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
textRenderData: props.textRenderData,
textScrollRefs: props.textScrollRefs,
});
return (
<>
<FilePaneFindBarSlot paneFind={paneFind} />
<FilePaneTextPreview {...props} findHighlightsByLine={findHighlightsByLine} />
</>
);
}
function FilePaneImagePreview({
imagePreviewUri,
imageSource,
previewScrollRef,
scrollbar,
showDesktopWebScrollbar,
}: FilePaneImagePreviewProps) {
if (!imagePreviewUri) {
return (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</FilePaneCenterState>
);
}
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</>
);
}
function FilePreviewBody({
preview,
isLoading,
@@ -196,40 +671,50 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const filePath = location.path;
const isDark = theme.colorScheme === "dark";
const matchBackgroundColor = isDark ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)";
const currentMatchBackgroundColor = isDark
? "rgba(251, 146, 60, 0.58)"
: "rgba(251, 146, 60, 0.48)";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null);
const fallbackScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
const textRenderData = useMemo(() => {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
return createFilePaneTextRenderData(preview.content ?? "", filePath);
}, [isMarkdownFile, preview, filePath]);
const textLineNumbers = useMemo(
() => textRenderData?.lines.map((line) => line.lineNumber) ?? null,
[textRenderData],
);
const textScrollRefs = useFilePaneTextScrollRefs(textLineNumbers);
const previewScrollRef = textRenderData ? textScrollRefs.previewScrollRef : fallbackScrollRef;
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
if (!textRenderData) return 0;
return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
}, [textRenderData, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
if (!textRenderData) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
lineCount: textRenderData.lines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
}, [textRenderData, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
@@ -247,141 +732,83 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
}, [lineHeight, lineSelection, previewScrollRef]);
let content: React.ReactNode;
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
content = (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
</FilePaneCenterState>
);
}
if (!preview) {
return (
<View style={styles.centerState}>
} else if (!preview) {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>No preview available</Text>
</View>
</FilePaneCenterState>
);
} else if (preview.kind === "text" && textRenderData) {
content = (
<FilePaneSearchableTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
lineSelection={lineSelection}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "text") {
content = (
<FilePaneTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
findHighlightsByLine={new Map()}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
lineSelection={lineSelection}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "image") {
content = (
<FilePaneImagePreview
imagePreviewUri={imagePreviewUri}
imageSource={imageSource}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
);
} else {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</FilePaneCenterState>
);
}
if (preview.kind === "text") {
if (isMarkdownFile) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
return <View style={styles.previewScrollContainer}>{content}</View>;
}
export function FilePane({
+198 -18
View File
@@ -124,6 +124,7 @@ interface UserMessageProps {
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
findHighlights?: MessageFindHighlight[];
}
const MessageOuterSpacingContext = createContext(false);
@@ -158,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
] as const;
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
export interface MessageFindHighlight {
id: string;
start: number;
end: number;
isCurrent: boolean;
}
interface MessageFindTextSegment {
key: string;
text: string;
highlight?: MessageFindHighlight;
}
interface MarkdownWithStableRendererProps {
children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>;
@@ -209,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5;
export const STREAM_METADATA_FONT_SIZE = 13;
type ScrollAxis = "x" | "y";
function normalizeFindHighlights(
textLength: number,
highlights: MessageFindHighlight[] | undefined,
): MessageFindHighlight[] {
if (!highlights || highlights.length === 0) {
return [];
}
return highlights
.map((highlight) => ({
...highlight,
start: Math.max(0, Math.min(textLength, highlight.start)),
end: Math.max(0, Math.min(textLength, highlight.end)),
}))
.filter((highlight) => highlight.start < highlight.end)
.sort((left, right) => left.start - right.start || left.end - right.end);
}
function createMessageFindTextSegments(
text: string,
highlights: MessageFindHighlight[] | undefined,
): MessageFindTextSegment[] {
const normalizedHighlights = normalizeFindHighlights(text.length, highlights);
if (normalizedHighlights.length === 0) {
return [{ key: "text", text }];
}
const segments: MessageFindTextSegment[] = [];
let cursor = 0;
for (const highlight of normalizedHighlights) {
if (cursor < highlight.start) {
segments.push({
key: `plain:${cursor}:${highlight.start}`,
text: text.slice(cursor, highlight.start),
});
}
segments.push({
key: highlight.id,
text: text.slice(highlight.start, highlight.end),
highlight,
});
cursor = highlight.end;
}
if (cursor < text.length) {
segments.push({
key: `plain:${cursor}:${text.length}`,
text: text.slice(cursor),
});
}
return segments;
}
interface MarkdownBlockWithOffset {
key: string;
block: string;
startOffset: number;
}
function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] {
let cursor = 0;
return splitMarkdownBlocks(message).map((block, index) => {
const startOffset = Math.max(cursor, message.indexOf(block, cursor));
cursor = startOffset + block.length;
return {
key: `${index}:${block.slice(0, 32)}`,
block,
startOffset,
};
});
}
function createBlockFindHighlights(
blockStartOffset: number,
blockLength: number,
highlights: MessageFindHighlight[] | undefined,
): MessageFindHighlight[] {
const blockEndOffset = blockStartOffset + blockLength;
return normalizeFindHighlights(blockEndOffset, highlights)
.filter((highlight) => highlight.start < blockEndOffset && highlight.end > blockStartOffset)
.map((highlight) => ({
id: highlight.id,
start: Math.max(0, highlight.start - blockStartOffset),
end: Math.min(blockLength, highlight.end - blockStartOffset),
isCurrent: highlight.isCurrent,
}));
}
function ensureWebToolCallShimmerKeyframes() {
if (isNative) {
return;
@@ -369,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
lineHeight: 22,
overflowWrap: "anywhere",
},
findMatchText: {
backgroundColor:
theme.colorScheme === "dark" ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)",
},
findCurrentMatchText: {
backgroundColor:
theme.colorScheme === "dark" ? "rgba(251, 146, 60, 0.58)" : "rgba(251, 146, 60, 0.48)",
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -436,6 +547,28 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function MessageFindHighlightedText({ segments }: { segments: MessageFindTextSegment[] }) {
return (
<>
{segments.map((segment) => (
<Text key={segment.key} style={getMessageFindHighlightStyle(segment.highlight)}>
{segment.text}
</Text>
))}
</>
);
}
function getMessageFindHighlightStyle(highlight: MessageFindHighlight | undefined) {
if (highlight?.isCurrent) {
return userMessageStylesheet.findCurrentMatchText;
}
if (highlight) {
return userMessageStylesheet.findMatchText;
}
return undefined;
}
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
@@ -475,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
findHighlights,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -498,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
},
[rewindMutation],
);
const highlightedMessageSegments = useMemo(
() => createMessageFindTextSegments(message, findHighlights),
[findHighlights, message],
);
const containerStyle = useMemo(
() => [
@@ -567,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
<MessageFindHighlightedText segments={highlightedMessageSegments} />
</Text>
) : null}
</View>
@@ -752,6 +890,7 @@ interface AssistantMessageProps {
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
findHighlights?: MessageFindHighlight[];
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -1487,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
findHighlights?: MessageFindHighlight[];
}
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
@@ -1494,26 +1634,61 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
rules,
parser,
onLinkPress,
findHighlights,
}: MemoizedMarkdownBlockProps) {
const textCursorRef = useRef(0);
textCursorRef.current = 0;
const renderFindHighlightedText = useCallback(
(content: string) => {
if (!findHighlights || findHighlights.length === 0 || content.length === 0) {
return content;
}
const start = text.indexOf(content, textCursorRef.current);
if (start < 0) {
return content;
}
textCursorRef.current = start + content.length;
const end = start + content.length;
const contentHighlights = findHighlights
.filter((highlight) => highlight.start < end && highlight.end > start)
.map((highlight) => ({
id: highlight.id,
start: Math.max(0, highlight.start - start),
end: Math.min(content.length, highlight.end - start),
isCurrent: highlight.isCurrent,
}));
const segments = createMessageFindTextSegments(content, contentHighlights);
return <MessageFindHighlightedText segments={segments} />;
},
[findHighlights, text],
);
return (
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
<MarkdownFindHighlightContext.Provider value={renderFindHighlightedText}>
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</MarkdownFindHighlightContext.Provider>
);
});
const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null);
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
children: ReactNode;
disableFindHighlight?: boolean;
}
function MarkdownInheritedText({
@@ -1521,12 +1696,18 @@ function MarkdownInheritedText({
textStyle,
style: overrideStyle,
children,
disableFindHighlight = false,
}: MarkdownInheritedTextProps) {
const renderFindHighlightedText = useContext(MarkdownFindHighlightContext);
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
const renderedChildren =
!disableFindHighlight && typeof children === "string" && renderFindHighlightedText
? renderFindHighlightedText(children)
: children;
return <MarkdownTextSpan style={style}>{renderedChildren}</MarkdownTextSpan>;
}
interface MarkdownListItemContentProps {
@@ -1559,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId,
client,
spacing = "default",
findHighlights,
}: AssistantMessageProps) {
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
@@ -1696,6 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
disableFindHighlight
>
{content}
</MarkdownInheritedText>
@@ -1803,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({
};
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
() => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
[blocks],
);
const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]);
const assistantContainerStyle = useMemo(
() => [
@@ -1822,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View testID="assistant-message" style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => (
{keyedBlocks.map(({ key, block, startOffset }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
@@ -1833,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleMarkdownLinkPress}
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
/>
</AssistantMessageBlockContainer>
))}
@@ -47,7 +47,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getNativeScrollToIndexFallbackOffset } from "@/agent-stream/strategy-native";
describe("getNativeScrollToIndexFallbackOffset", () => {
it("approximates the offset for an unmeasured native FlatList row", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: 72,
}),
).toBe(1800);
});
it("falls back to the start when the average item length is not useful", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: 0,
}),
).toBe(0);
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: Number.NaN,
}),
).toBe(0);
});
});
@@ -22,6 +22,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
TerminalEmulatorRuntime,
type TerminalFindResultChangeEvent,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle {
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
blur: () => void;
findNext: (input: { query: string }) => boolean;
findPrevious: (input: { query: string }) => boolean;
clearFindDecorations: () => void;
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
@@ -328,6 +333,18 @@ export default function TerminalEmulator({
blur: () => {
runtimeRef.current?.blur();
},
findNext: (input: { query: string }) => {
return runtimeRef.current?.findNext(input) ?? false;
},
findPrevious: (input: { query: string }) => {
return runtimeRef.current?.findPrevious(input) ?? false;
},
clearFindDecorations: () => {
runtimeRef.current?.clearFindDecorations();
},
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => {
return runtimeRef.current?.onFindResultsChanged(listener) ?? (() => {});
},
}),
[],
);
@@ -0,0 +1,452 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useImperativeHandle } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TerminalPane } from "@/components/terminal-pane";
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
type PaneContextValue,
} from "@/panels/pane-context";
import {
clearActivePaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
interface FindResultChange {
resultIndex: number;
resultCount: number;
}
interface TerminalKeyInput {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
interface MockTerminalEmulatorProps {
testId?: string;
onTerminalKey?: (input: TerminalKeyInput) => Promise<void> | void;
}
const { client, findListeners, terminalProps, theme, terminalHandle, resetTerminalHandle } =
vi.hoisted(() => {
const listeners: Array<(event: FindResultChange) => void> = [];
const handle = {
writeOutput: vi.fn(),
restoreOutput: vi.fn(),
renderSnapshot: vi.fn(),
clear: vi.fn(),
blur: vi.fn(),
findNext: vi.fn(() => true),
findPrevious: vi.fn(() => true),
clearFindDecorations: vi.fn(),
onFindResultsChanged: vi.fn((listener: (event: FindResultChange) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
};
}),
};
return {
client: {
on: vi.fn(() => () => {}),
sendTerminalInput: vi.fn(),
},
findListeners: listeners,
terminalProps: {
current: null as null | MockTerminalEmulatorProps,
},
terminalHandle: handle,
resetTerminalHandle: () => {
handle.writeOutput.mockClear();
handle.renderSnapshot.mockClear();
handle.clear.mockClear();
handle.findNext.mockClear();
handle.findPrevious.mockClear();
handle.clearFindDecorations.mockClear();
handle.onFindResultsChanged.mockClear();
listeners.splice(0);
},
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500" as const },
borderRadius: { md: 6 },
colors: {
background: "#000",
border: "#333",
destructive: "#f43f5e",
foreground: "#fff",
foregroundMuted: "#aaa",
primary: "#0a84ff",
surface0: "#111",
surface1: "#222",
surface2: "#333",
terminal: {
background: "#000",
foreground: "#fff",
cursor: "#fff",
cursorAccent: "#000",
selectionBackground: "#444",
selectionForeground: "#fff",
black: "#000",
red: "#f00",
green: "#0f0",
yellow: "#ff0",
blue: "#00f",
magenta: "#f0f",
cyan: "#0ff",
white: "#fff",
brightBlack: "#555",
brightRed: "#f55",
brightGreen: "#5f5",
brightYellow: "#ff5",
brightBlue: "#55f",
brightMagenta: "#f5f",
brightCyan: "#5ff",
brightWhite: "#fff",
},
},
},
};
});
vi.mock("react-native", () => {
const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children);
const MockText = ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children);
const MockPressable = ({
children,
disabled,
onPress,
testID,
}: {
children?: React.ReactNode;
disabled?: boolean;
onPress?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"data-testid": testID,
disabled,
onClick: () => {
if (!disabled) {
onPress?.();
}
},
type: "button",
},
children,
);
const MockTextInput = React.forwardRef<
HTMLInputElement,
{
value?: string;
onChangeText?: (text: string) => void;
onKeyPress?: (event: {
nativeEvent: { key: string; shiftKey?: boolean };
preventDefault: () => void;
}) => void;
testID?: string;
placeholder?: string;
}
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
return React.createElement("input", {
"data-testid": testID,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
onKeyPress?.({
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
preventDefault: () => event.preventDefault(),
}),
placeholder,
ref,
value: value ?? "",
});
});
return {
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
Platform: {
OS: "web",
select: (options: Record<string, unknown>) => options.web ?? options.default,
},
Pressable: MockPressable,
ScrollView: MockView,
Text: MockText,
TextInput: MockTextInput,
View: MockView,
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
},
runOnJS: (fn: () => void) => fn,
useAnimatedReaction: vi.fn(),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
absoluteFillObject: {},
hairlineWidth: 1,
create: (factory: unknown) =>
Object.prototype.toString.call(factory) === "[object Function]"
? (factory as (value: unknown) => unknown)(theme)
: factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon() {
return React.createElement("span", { "data-icon": name });
};
return {
ChevronDown: createIcon("ChevronDown"),
ChevronUp: createIcon("ChevronUp"),
X: createIcon("X"),
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => client,
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/hooks/use-app-visible", () => ({
useAppVisible: () => true,
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({
shift: { value: 0 },
style: null,
}),
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { terminalScrollbackLines: 1000 } }),
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@/stores/panel-store", () => ({
usePanelStore: (
selector: (state: { mobileView: string; showMobileAgentList: () => void }) => unknown,
) => selector({ mobileView: "agent", showMobileAgentList: vi.fn() }),
}));
vi.mock("@/terminal/runtime/workspace-terminal-session", () => ({
getWorkspaceTerminalSession: () => ({
snapshots: {
clear: vi.fn(),
get: vi.fn(() => null),
set: vi.fn(),
},
}),
}));
vi.mock("@/terminal/runtime/terminal-stream-controller", () => ({
TerminalStreamController: class {
dispose() {}
setTerminal() {}
},
}));
vi.mock("@/components/terminal-emulator", () => ({
default: React.forwardRef<TerminalEmulatorHandle, MockTerminalEmulatorProps>(
function TerminalEmulator(props, ref) {
terminalProps.current = props;
useImperativeHandle(ref, () => terminalHandle);
return React.createElement("div", { "data-testid": props.testId ?? "terminal-surface" });
},
),
}));
const paneInstanceId = "server-a:workspace-a:left";
const paneContext: PaneContextValue = {
serverId: "server-a",
workspaceId: "workspace-a",
paneInstanceId,
tabId: "terminal",
target: { kind: "terminal", terminalId: "terminal-a" },
openTab: () => {},
closeCurrentTab: () => {},
retargetCurrentTab: () => {},
openFileInWorkspace: () => {},
openImportSheet: () => {},
};
const paneFocus = createPaneFocusContextValue({
isPaneFocused: true,
isWorkspaceFocused: true,
});
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
resetTerminalHandle();
client.sendTerminalInput.mockClear();
client.on.mockClear();
terminalProps.current = null;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
clearActivePaneFindPaneId(paneInstanceId);
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function renderTerminalPane(): void {
act(() => {
root?.render(
<PaneProvider value={paneContext}>
<PaneFocusProvider value={paneFocus}>
<TerminalPane
serverId="server-a"
cwd="/repo"
terminalId="terminal-a"
isWorkspaceFocused
isPaneFocused
onOpenFileExplorer={vi.fn()}
onOpenWorkspaceFile={vi.fn()}
/>
</PaneFocusProvider>
</PaneProvider>,
);
});
setActivePaneFindPaneId(paneInstanceId);
}
function inputElement(): HTMLInputElement {
const input = container?.querySelector('[data-testid="pane-find-input"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
function changeInput(value: string): void {
const input = inputElement();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
}
function pressFindKey(key: string, shiftKey = false): void {
act(() => {
inputElement().dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
});
}
function click(testId: string): void {
const element = container?.querySelector(`[data-testid="${testId}"]`);
expect(element).toBeInstanceOf(HTMLElement);
act(() => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function emitFindResults(event: FindResultChange): void {
act(() => {
for (const listener of findListeners) {
listener(event);
}
});
}
describe("TerminalPane find", () => {
it("searches through xterm, navigates matches, and clears decorations", () => {
renderTerminalPane();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("needle");
expect(container?.textContent).toContain("Searching...");
emitFindResults({ resultIndex: 2, resultCount: 7 });
expect(terminalHandle.findNext).toHaveBeenCalledWith({ query: "needle" });
expect(container?.textContent).toContain("3 / 7");
click("pane-find-next");
expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" });
expect(container?.textContent).toContain("Searching...");
emitFindResults({ resultIndex: 3, resultCount: 7 });
click("pane-find-prev");
expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" });
emitFindResults({ resultIndex: 2, resultCount: 7 });
pressFindKey("Enter");
expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" });
emitFindResults({ resultIndex: 3, resultCount: 7 });
pressFindKey("Enter", true);
expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" });
changeInput("");
expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(1);
expect(container?.textContent).toContain("0 / 0");
changeInput("needle");
click("pane-find-close");
expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(2);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("keeps terminal key input flowing while the find bar is open", async () => {
renderTerminalPane();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
await act(async () => {
await terminalProps.current?.onTerminalKey?.({
key: "c",
ctrl: true,
shift: false,
alt: false,
meta: false,
});
});
expect(client.sendTerminalInput).toHaveBeenCalledWith("terminal-a", {
type: "input",
data: "\u0003",
});
});
});
+76 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -47,6 +47,8 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime";
interface TerminalPaneProps {
serverId: string;
@@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = {
alt: false,
};
const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" };
const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" };
const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" };
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
function terminalFindStateFromResult(event: TerminalFindResultChangeEvent): PaneFindMatchState {
if (event.resultCount <= 0 || event.resultIndex < 0) {
return NO_FIND_MATCH_STATE;
}
return {
status: "matched",
current: event.resultIndex + 1,
total: event.resultCount,
};
}
interface ModifierButtonProps {
modifier: keyof ModifierState;
active: boolean;
@@ -212,9 +229,16 @@ export function TerminalPane({
win32InputMode: false,
});
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const terminalFindQueryRef = useRef("");
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
const [terminalFindMatchState, setTerminalFindMatchStateValue] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => {
setTerminalFindMatchStateValue(nextState);
}, []);
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -224,6 +248,19 @@ export function TerminalPane({
};
}, [terminalId]);
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
return emulatorRef.current?.onFindResultsChanged((event) => {
if (terminalFindQueryRef.current.length === 0) {
return;
}
setTerminalFindMatchState(terminalFindStateFromResult(event));
});
}, [isWorkspaceFocused, setTerminalFindMatchState, terminalId]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -251,6 +288,43 @@ export function TerminalPane({
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
);
const clearTerminalFind = useCallback(() => {
terminalFindQueryRef.current = "";
emulatorRef.current?.clearFindDecorations();
setTerminalFindMatchState(EMPTY_FIND_MATCH_STATE);
}, [setTerminalFindMatchState]);
const runTerminalFind = useCallback(
(query: string, direction: "next" | "previous"): PaneFindMatchState => {
terminalFindQueryRef.current = query;
if (query.length === 0) {
clearTerminalFind();
return EMPTY_FIND_MATCH_STATE;
}
const found =
direction === "previous"
? (emulatorRef.current?.findPrevious({ query }) ?? false)
: (emulatorRef.current?.findNext({ query }) ?? false);
if (!found) {
setTerminalFindMatchState(NO_FIND_MATCH_STATE);
return NO_FIND_MATCH_STATE;
}
setTerminalFindMatchState(PENDING_FIND_MATCH_STATE);
return PENDING_FIND_MATCH_STATE;
},
[clearTerminalFind, setTerminalFindMatchState],
);
const paneFind = usePaneFind({
matchState: terminalFindMatchState,
onQuery: (query) => runTerminalFind(query, "next"),
onNext: () => runTerminalFind(terminalFindQueryRef.current, "next"),
onPrev: () => runTerminalFind(terminalFindQueryRef.current, "previous"),
onClose: clearTerminalFind,
});
useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
@@ -748,6 +822,7 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.outputContainer}>
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
@@ -118,4 +118,44 @@ describe("bottom sheet visibility tracker", () => {
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(2);
});
it("does not re-present when the controller reattaches before parent state acknowledges a user dismiss", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(sheet.events).toEqual([{ type: "present" }]);
});
it("does not re-present when dismiss fires before parent state acknowledges a user dismiss", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetDismiss();
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(sheet.events).toEqual([{ type: "present" }]);
});
it("allows a fresh open after parent state acknowledges a dismissed sheet", () => {
const { sheet, tracker } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.attachController(null);
tracker.attachController(sheet);
tracker.syncDesired({ visible: false });
tracker.syncDesired({ visible: true });
expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]);
});
});
@@ -15,25 +15,27 @@ export interface BottomSheetVisibilityTracker {
handleSheetDismiss(): void;
}
type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
export function createBottomSheetVisibilityTracker(opts: {
onClose: () => void;
}): BottomSheetVisibilityTracker {
let controller: BottomSheetController | null = null;
let visible = false;
let isEnabled: boolean | undefined;
let isPresented = false;
let phase: BottomSheetPhase = "closed";
let hasNotifiedClose = false;
function present(): void {
if (!controller || isPresented) return;
isPresented = true;
if (!controller || phase !== "closed") return;
phase = "presenting";
hasNotifiedClose = false;
controller.present();
}
function dismiss(): void {
if (!controller || !isPresented) return;
isPresented = false;
if (!controller || phase === "closed" || phase === "dismissing") return;
phase = "dismissing";
controller.dismiss();
}
@@ -58,19 +60,34 @@ export function createBottomSheetVisibilityTracker(opts: {
present();
return;
}
if (phase === "dismissing") {
phase = "closed";
hasNotifiedClose = false;
return;
}
dismiss();
},
handleSheetIndexChange(index) {
if (index === -1 && visible) {
if (index !== -1) {
if (phase === "presenting") {
phase = "presented";
}
return;
}
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
}
if (visible) {
notifyClose();
}
},
handleSheetDismiss() {
isPresented = false;
if (visible) {
phase = "dismissing";
notifyClose();
return;
}
phase = "closed";
hasNotifiedClose = false;
},
};
+25
View File
@@ -92,10 +92,35 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserFindOptions {
forward?: boolean;
findNext?: boolean;
matchCase?: boolean;
}
export type DesktopBrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
export interface DesktopBrowserFoundInPageResult {
requestId?: number;
activeMatchOrdinal?: number;
matches?: number;
finalUpdate?: boolean;
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
findInPage: (
browserId: string,
text: string,
options?: DesktopBrowserFindOptions,
) => Promise<number | null> | number | null;
stopFindInPage: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
onFoundInPage: (
browserId: string,
listener: (result: DesktopBrowserFoundInPageResult) => void,
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
@@ -34,7 +34,7 @@ afterEach(() => {
});
describe("ensureAgentIsInitialized", () => {
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => {
it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
@@ -56,12 +56,12 @@ describe("ensureAgentIsInitialized", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded canonical tail when no authoritative cursor is available", () => {
it("requests a bounded projected tail when no authoritative cursor is available", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -75,7 +75,7 @@ describe("ensureAgentIsInitialized", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
@@ -107,7 +107,7 @@ describe("ensureAgentIsInitialized", () => {
});
describe("refreshAgent", () => {
it("fetches a bounded canonical tail after refreshing the agent", async () => {
it("fetches a bounded projected tail after refreshing the agent", async () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -121,7 +121,7 @@ describe("refreshAgent", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
});
@@ -27,6 +27,25 @@ import {
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { handlePaneFindKeyboardAction } from "@/panels/pane-find-registry";
type KeyboardShortcutResult = ReturnType<typeof resolveKeyboardShortcut>;
function applyPendingChordEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) {
if (result.preventDefault && !result.match) {
event.preventDefault();
event.stopPropagation();
}
}
function applyHandledShortcutEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) {
if (result.preventDefault || result.match?.preventDefault) {
event.preventDefault();
}
if (result.preventDefault || result.match?.stopPropagation) {
event.stopPropagation();
}
}
export function useKeyboardShortcuts({
enabled,
@@ -94,6 +113,8 @@ export function useKeyboardShortcuts({
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "pane-find-open":
return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
@@ -182,10 +203,7 @@ export function useKeyboardShortcuts({
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
applyPendingChordEventHandling(event, result);
if (!result.match) {
return;
@@ -208,12 +226,7 @@ export function useKeyboardShortcuts({
return;
}
if (result.match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
event.stopPropagation();
}
applyHandledShortcutEventHandling(event, result);
};
const handleKeyUp = (event: KeyboardEvent) => {
@@ -147,7 +147,7 @@ describe("loadOlderAgentHistory", () => {
direction: "before",
cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
},
},
]);
@@ -10,7 +10,7 @@ export interface LoadOlderAgentHistoryClient {
direction: "before";
cursor: { epoch: string; seq: number };
limit: number;
projection: "canonical";
projection: "projected";
},
) => Promise<unknown>;
}
+1
View File
@@ -20,6 +20,7 @@ export type KeyboardActionId =
| "agent.new"
| "workspace.tab.new"
| "workspace.tab.close.current"
| "workspace.find.open"
| "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative"
| "workspace.pane.split.right"
@@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current",
},
{
name: "matches Cmd+F to open find in the focused pane",
event: { key: "f", code: "KeyF", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
action: "workspace.find.open",
},
{
name: "matches Ctrl+F to open find in the focused pane on non-mac",
event: { key: "f", code: "KeyF", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
action: "workspace.find.open",
},
{
name: "matches Ctrl+W to close current tab on non-mac desktop",
event: { key: "w", code: "KeyW", ctrlKey: true },
@@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -250,6 +250,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Find in pane ---
{
id: "workspace-find-open-cmd-f-mac",
action: "workspace.find.open",
combo: "Cmd+F",
when: { mac: true, commandCenter: false },
help: {
id: "workspace-find-open",
section: "tabs-panes",
label: "Find in pane",
keys: ["mod", "F"],
},
},
{
id: "workspace-find-open-ctrl-f-non-mac",
action: "workspace.find.open",
combo: "Ctrl+F",
when: { mac: false, commandCenter: false },
help: {
id: "workspace-find-open",
section: "tabs-panes",
label: "Find in pane",
keys: ["mod", "F"],
},
},
// --- Workspace index jump ---
{
id: "workspace-navigate-index-cmd-digit-mac",
+4 -1
View File
@@ -37,7 +37,8 @@ export type ShortcutAction =
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }
| { kind: "pane-find-open" };
const NONE: ShortcutAction = { kind: "none" };
@@ -201,6 +202,8 @@ export function routeKeyboardShortcut(
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
case "workspace.find.open":
return { kind: "pane-find-open" };
default:
return NONE;
}
+1
View File
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
paneInstanceId: string | null;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import {
clearActivePaneFindPaneId,
createPaneFindRegistry,
handlePaneFindKeyboardAction,
paneFindRegistry,
setActivePaneFindPaneId,
type PaneFindController,
} from "@/panels/pane-find-registry";
function createController(input?: { openResult?: boolean }): PaneFindController {
return {
openFind: vi.fn(() => input?.openResult ?? true),
closeFind: vi.fn(() => true),
};
}
describe("pane find registry", () => {
it("routes open find to the active pane instance", () => {
const activePaneId = { current: "server:workspace:left" };
const registry = createPaneFindRegistry({
getActivePaneId: () => activePaneId.current,
});
const left = createController({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(false);
});
it("stops routing to a pane after it unregisters", () => {
const registry = createPaneFindRegistry({
getActivePaneId: () => "server:workspace:left",
});
const controller = createController();
const unregister = registry.register({
paneId: "server:workspace:left",
controller,
});
unregister();
expect(registry.openFindInActivePane()).toBe(false);
expect(controller.closeFind).toHaveBeenCalledTimes(1);
});
it("keeps split panes with the same target distinct by pane instance", () => {
const activePaneId = { current: "server:workspace:right" };
const registry = createPaneFindRegistry({
getActivePaneId: () => activePaneId.current,
});
const left = createController({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(true);
});
it("handles the keyboard find action through the active pane", () => {
const controller = createController();
const unregister = paneFindRegistry.register({
paneId: "server:workspace:left",
controller,
});
setActivePaneFindPaneId("server:workspace:left");
expect(handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" })).toBe(
true,
);
expect(controller.openFind).toHaveBeenCalledTimes(1);
clearActivePaneFindPaneId("server:workspace:left");
expect(controller.closeFind).toHaveBeenCalledTimes(1);
unregister();
});
it("closes the previous active pane when pane focus changes", () => {
const left = createController();
const right = createController();
const unregisterLeft = paneFindRegistry.register({
paneId: "server:workspace:left",
controller: left,
});
const unregisterRight = paneFindRegistry.register({
paneId: "server:workspace:right",
controller: right,
});
setActivePaneFindPaneId("server:workspace:left");
setActivePaneFindPaneId("server:workspace:right");
expect(left.closeFind).toHaveBeenCalledTimes(1);
expect(right.closeFind).not.toHaveBeenCalled();
clearActivePaneFindPaneId("server:workspace:right");
unregisterLeft();
unregisterRight();
});
});
@@ -0,0 +1,82 @@
export interface PaneFindController {
openFind(): boolean;
closeFind(): boolean;
}
interface PaneFindKeyboardAction {
id: "workspace.find.open";
scope: "workspace";
}
interface RegisterPaneFindInput {
paneId: string;
controller: PaneFindController;
}
interface PaneFindRegistry {
register(input: RegisterPaneFindInput): () => void;
openFindInActivePane(): boolean;
closeFindInPane(paneId: string): boolean;
}
export function createPaneFindRegistry(input: { getActivePaneId: () => string | null }) {
const controllers = new Map<string, PaneFindController>();
return {
register({ paneId, controller }: RegisterPaneFindInput) {
controllers.set(paneId, controller);
return () => {
if (controllers.get(paneId) === controller) {
controller.closeFind();
controllers.delete(paneId);
}
};
},
openFindInActivePane(): boolean {
const activePaneId = input.getActivePaneId();
if (!activePaneId) {
return false;
}
return controllers.get(activePaneId)?.openFind() ?? false;
},
closeFindInPane(paneId: string): boolean {
return controllers.get(paneId)?.closeFind() ?? false;
},
} satisfies PaneFindRegistry;
}
let activePaneId: string | null = null;
export const paneFindRegistry = createPaneFindRegistry({
getActivePaneId: () => activePaneId,
});
export function createPaneFindPaneId(input: {
serverId: string;
workspaceId: string;
paneId: string;
}): string {
return `${input.serverId}:${input.workspaceId}:${input.paneId}`;
}
export function setActivePaneFindPaneId(paneId: string | null) {
if (activePaneId && activePaneId !== paneId) {
paneFindRegistry.closeFindInPane(activePaneId);
}
activePaneId = paneId;
}
export function clearActivePaneFindPaneId(paneId: string) {
if (activePaneId === paneId) {
paneFindRegistry.closeFindInPane(paneId);
activePaneId = null;
}
}
export function handlePaneFindKeyboardAction(action: PaneFindKeyboardAction): boolean {
void action;
return paneFindRegistry.openFindInActivePane();
}
+503
View File
@@ -0,0 +1,503 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Text, View } from "react-native";
import {
FindBar,
usePaneFind,
type PaneFindCommandResult,
type PaneFindMatchState,
} from "@/panels/pane-find";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
usePaneContext,
type PaneContextValue,
} from "@/panels/pane-context";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
const { theme } = vi.hoisted(() => ({
theme: {
colors: {
border: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#111",
surface1: "#222",
},
},
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon() {
return React.createElement("span", { "data-icon": name });
};
return {
ChevronDown: createIcon("ChevronDown"),
ChevronUp: createIcon("ChevronUp"),
X: createIcon("X"),
};
});
vi.mock("react-native", () => {
const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children);
const MockText = ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children);
const MockPressable = ({
children,
disabled,
onPress,
testID,
}: {
children?: React.ReactNode;
disabled?: boolean;
onPress?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"data-testid": testID,
disabled,
onClick: () => {
if (!disabled) onPress?.();
},
type: "button",
},
children,
);
const MockTextInput = React.forwardRef<
HTMLInputElement,
{
value?: string;
onChangeText?: (text: string) => void;
onKeyPress?: (event: {
nativeEvent: { key: string; shiftKey?: boolean };
preventDefault: () => void;
}) => void;
testID?: string;
placeholder?: string;
}
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
return React.createElement("input", {
"data-testid": testID,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
onKeyPress?.({
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
preventDefault: () => event.preventDefault(),
}),
placeholder,
ref,
value: value ?? "",
});
});
return { Pressable: MockPressable, Text: MockText, TextInput: MockTextInput, View: MockView };
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
hairlineWidth: 1,
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/panels/register-panels", () => ({
ensurePanelsRegistered: vi.fn(),
}));
vi.mock("@/panels/panel-registry", () => ({
getPanelRegistration: () => ({
kind: "agent",
component: FakeFindPanel,
useDescriptor: vi.fn(),
}),
}));
interface FakeSearchController {
query: ReturnType<typeof vi.fn<(query: string) => PaneFindCommandResult>>;
next: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
prev: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
close: ReturnType<typeof vi.fn<() => void>>;
}
const controllers = new Map<string, FakeSearchController>();
function createController(input?: { total?: number }): FakeSearchController {
const total = input?.total ?? 3;
return {
query: vi.fn((query: string) =>
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total },
),
next: vi.fn(() => ({ status: "matched", current: 2, total })),
prev: vi.fn(() => ({ status: "matched", current: 3, total })),
close: vi.fn(),
};
}
function FakeFindPanel() {
const paneContext = usePaneContext();
const controller = controllers.get(paneContext.paneInstanceId ?? "");
if (!controller) {
throw new Error(`Missing fake find controller for pane ${paneContext.paneInstanceId}`);
}
const paneFind = usePaneFind({
onQuery: controller.query,
onNext: controller.next,
onPrev: controller.prev,
onClose: controller.close,
});
return (
<View>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<Text>Pane body</Text>
</View>
);
}
function AsyncFindPanel({
matchState,
onQuery,
}: {
matchState: PaneFindMatchState;
onQuery: (query: string) => PaneFindCommandResult;
}) {
const paneFind = usePaneFind({
matchState,
onQuery,
onNext: () => undefined,
onPrev: () => undefined,
onClose: vi.fn(),
});
return (
<View>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<Text>Pane body</Text>
</View>
);
}
const tab: WorkspaceTabDescriptor = {
key: "agent_agent-a",
tabId: "agent_agent-a",
kind: "agent",
target: { kind: "agent", agentId: "agent-a" },
};
const leftPaneInstanceId = createPaneFindPaneId({
serverId: "server-a",
workspaceId: "workspace-a",
paneId: "left",
});
const harnessPaneContext: PaneContextValue = {
serverId: "server-a",
workspaceId: "workspace-a",
paneInstanceId: leftPaneInstanceId,
tabId: "agent_agent-a",
target: tab.target,
openTab: () => {},
closeCurrentTab: () => {},
retargetCurrentTab: () => {},
openFileInWorkspace: () => {},
openImportSheet: () => {},
};
const harnessPaneFocus = createPaneFocusContextValue({
isPaneFocused: true,
isWorkspaceFocused: true,
onFocusPane: () => {},
});
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
clearActivePaneFindPaneId("server-a:workspace-a:left");
clearActivePaneFindPaneId("server-a:workspace-a:right");
controllers.clear();
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function renderFindHarness(controller: FakeSearchController = createController()) {
controllers.set(leftPaneInstanceId, controller);
act(() => {
root?.render(
<PaneProvider value={harnessPaneContext}>
<PaneFocusProvider value={harnessPaneFocus}>
<FakeFindPanel />
</PaneFocusProvider>
</PaneProvider>,
);
});
setActivePaneFindPaneId(leftPaneInstanceId);
}
function inputElement(): HTMLInputElement {
const input = container?.querySelector('[data-testid="pane-find-input"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
function button(testId: string): HTMLElement {
const element = container?.querySelector(`[data-testid="${testId}"]`);
expect(element).toBeInstanceOf(HTMLElement);
return element as HTMLElement;
}
function changeInput(value: string): void {
const input = inputElement();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
}
function pressKey(key: string, shiftKey = false): void {
const input = inputElement();
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
});
}
describe("FindBar", () => {
it("opens through pane registration and focuses the query input", () => {
renderFindHarness();
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
expect(inputElement()).toBe(document.activeElement);
expect(container?.textContent).toContain("Find");
});
it("dispatches query changes and renders match, empty, and no-match states", () => {
const controller = createController();
renderFindHarness(controller);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
expect(container?.textContent).toContain("0 / 0");
changeInput("abc");
expect(controller.query).toHaveBeenLastCalledWith("abc");
expect(container?.textContent).toContain("1 / 3");
changeInput("missing");
expect(container?.textContent).toContain("No matches");
changeInput("");
expect(container?.textContent).toContain("0 / 0");
});
it("handles next, previous, Escape, Enter, Shift+Enter, and close", () => {
const controller = createController();
renderFindHarness(controller);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
pressKey("Enter");
expect(controller.next).toHaveBeenCalledTimes(1);
expect(container?.textContent).toContain("2 / 3");
pressKey("Enter", true);
expect(controller.prev).toHaveBeenCalledTimes(1);
expect(container?.textContent).toContain("3 / 3");
act(() => {
button("pane-find-next").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.textContent).toContain("2 / 3");
act(() => {
button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.textContent).toContain("3 / 3");
pressKey("Escape");
expect(controller.close).toHaveBeenCalledTimes(1);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
act(() => {
button("pane-find-close").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("cleans up the active find adapter on pane deactivation and unmount", () => {
const controller = createController();
renderFindHarness(controller);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
act(() => {
clearActivePaneFindPaneId(leftPaneInstanceId);
});
expect(controller.close).toHaveBeenCalledTimes(1);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
act(() => {
setActivePaneFindPaneId(leftPaneInstanceId);
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
act(() => {
root?.unmount();
});
expect(controller.close).toHaveBeenCalledTimes(2);
root = null;
});
it("shows pending match metadata until an adapter-owned async result arrives", () => {
const onQuery = vi.fn(() => undefined);
let setExternalMatchState: ((matchState: PaneFindMatchState) => void) | null = null;
function Harness() {
const [externalMatchState, setMatchState] = React.useState<PaneFindMatchState>({
status: "empty",
});
setExternalMatchState = setMatchState;
return (
<PaneProvider value={harnessPaneContext}>
<PaneFocusProvider value={harnessPaneFocus}>
<AsyncFindPanel matchState={externalMatchState} onQuery={onQuery} />
</PaneFocusProvider>
</PaneProvider>
);
}
act(() => {
root?.render(<Harness />);
});
setActivePaneFindPaneId(leftPaneInstanceId);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("needle");
expect(onQuery).toHaveBeenCalledWith("needle");
expect(container?.textContent).toContain("Searching...");
act(() => {
setExternalMatchState?.({ status: "matched", current: 2, total: 4 });
});
expect(container?.textContent).toContain("2 / 4");
});
it("routes open find through the focused workspace pane without replacing pane focus", () => {
const left = createController({ total: 7 });
const right = createController({ total: 5 });
const leftContent = buildWorkspacePaneContentModel({
tab,
paneId: "left",
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
onOpenImportSheet: vi.fn(),
});
const rightContent = buildWorkspacePaneContentModel({
tab,
paneId: "right",
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
onOpenImportSheet: vi.fn(),
});
const focusLeft = vi.fn();
const focusRight = vi.fn();
controllers.set("server-a:workspace-a:left", left);
controllers.set("server-a:workspace-a:right", right);
act(() => {
root?.render(
<View>
<WorkspacePaneContent
content={leftContent}
isPaneFocused={false}
isWorkspaceFocused
onFocusPane={focusLeft}
/>
<WorkspacePaneContent
content={rightContent}
isPaneFocused
isWorkspaceFocused
onFocusPane={focusRight}
/>
</View>,
);
});
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
expect(container?.textContent).toContain("1 / 5");
expect(container?.textContent).not.toContain("1 / 7");
expect(focusLeft).not.toHaveBeenCalled();
expect(focusRight).not.toHaveBeenCalled();
});
});
+310
View File
@@ -0,0 +1,310 @@
import { ChevronDown, ChevronUp, X } from "lucide-react-native";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Pressable,
Text,
TextInput,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import { paneFindRegistry } from "@/panels/pane-find-registry";
export type PaneFindMatchState =
| { status: "empty" }
| { status: "pending" }
| { status: "no-match" }
| { status: "matched"; current: number; total: number };
export type PaneFindCommandResult = PaneFindMatchState | void;
export interface UsePaneFindInput {
matchState?: PaneFindMatchState;
onQuery(query: string): PaneFindCommandResult;
onNext(): PaneFindCommandResult;
onPrev(): PaneFindCommandResult;
onClose(): void;
}
export interface FindBarProps {
query: string;
matchState: PaneFindMatchState;
focusToken: number;
onQueryChange(query: string): void;
onNext(): void;
onPrev(): void;
onClose(): void;
}
export interface UsePaneFindResult {
isOpen: boolean;
findBarProps: FindBarProps;
}
export function usePaneFind(input: UsePaneFindInput): UsePaneFindResult {
const { paneInstanceId } = usePaneContext();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [matchState, setMatchState] = useState<PaneFindMatchState>({ status: "empty" });
const [focusToken, setFocusToken] = useState(0);
const onQuery = useStableEvent(input.onQuery);
const onNextInput = useStableEvent(input.onNext);
const onPrevInput = useStableEvent(input.onPrev);
const onCloseInput = useStableEvent(input.onClose);
const isOpenRef = useRef(false);
const queryRef = useRef("");
useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
if (!isOpen || !input.matchState) {
return;
}
setMatchState(input.matchState);
}, [input.matchState, isOpen]);
const openFind = useCallback(() => {
isOpenRef.current = true;
setIsOpen(true);
setFocusToken((current) => current + 1);
return true;
}, []);
const closeFind = useCallback(() => {
if (!isOpenRef.current && queryRef.current.length === 0) {
return false;
}
isOpenRef.current = false;
queryRef.current = "";
setIsOpen(false);
setQuery("");
setMatchState({ status: "empty" });
onCloseInput();
return true;
}, [onCloseInput]);
useEffect(() => {
if (!paneInstanceId) {
return;
}
return paneFindRegistry.register({
paneId: paneInstanceId,
controller: { openFind, closeFind },
});
}, [closeFind, openFind, paneInstanceId]);
useEffect(() => {
return () => {
closeFind();
};
}, [closeFind]);
const handleQueryChange = useCallback(
(nextQuery: string) => {
queryRef.current = nextQuery;
setQuery(nextQuery);
const nextState = onQuery(nextQuery);
setMatchState(
nextQuery.length === 0 ? { status: "empty" } : (nextState ?? { status: "pending" }),
);
},
[onQuery],
);
const handleNext = useCallback(() => {
if (queryRef.current.length === 0) {
return;
}
setMatchState(onNextInput() ?? { status: "pending" });
}, [onNextInput]);
const handlePrev = useCallback(() => {
if (queryRef.current.length === 0) {
return;
}
setMatchState(onPrevInput() ?? { status: "pending" });
}, [onPrevInput]);
const handleClose = useCallback(() => {
closeFind();
}, [closeFind]);
const findBarProps = useMemo(
() => ({
query,
matchState,
focusToken,
onQueryChange: handleQueryChange,
onNext: handleNext,
onPrev: handlePrev,
onClose: handleClose,
}),
[focusToken, handleClose, handleNext, handlePrev, handleQueryChange, matchState, query],
);
return { isOpen, findBarProps };
}
export function FindBar({
query,
matchState,
focusToken,
onQueryChange,
onNext,
onPrev,
onClose,
}: FindBarProps) {
const inputRef = useRef<TextInput>(null);
const { theme } = useUnistyles();
const iconColor = theme.colors?.foregroundMuted ?? "#71717a";
useEffect(() => {
inputRef.current?.focus();
}, [focusToken]);
const canNavigate = matchState.status === "matched";
const handleKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData & { shiftKey?: boolean }>) => {
if (event.nativeEvent.key === "Escape") {
event.preventDefault?.();
onClose();
return;
}
if (event.nativeEvent.key !== "Enter") {
return;
}
event.preventDefault?.();
if (!canNavigate) {
return;
}
if (event.nativeEvent.shiftKey) {
onPrev();
return;
}
onNext();
},
[canNavigate, onClose, onNext, onPrev],
);
const counterText = formatMatchState(matchState);
const controlDisabled = !canNavigate;
const matchButtonStyle = useMemo(
() => [styles.iconButton, controlDisabled && styles.iconButtonDisabled],
[controlDisabled],
);
return (
<View style={styles.container} testID="pane-find-bar">
<Text style={styles.label}>Find</Text>
<TextInput
ref={inputRef}
testID="pane-find-input"
value={query}
onChangeText={onQueryChange}
onKeyPress={handleKeyPress}
autoCapitalize="none"
autoCorrect={false}
placeholder="Search"
placeholderTextColor={theme.colors?.foregroundMuted ?? "#71717a"}
style={styles.input}
/>
<Text style={styles.counter}>{counterText}</Text>
<Pressable
accessibilityLabel="Previous match"
disabled={controlDisabled}
onPress={onPrev}
style={matchButtonStyle}
testID="pane-find-prev"
>
<ChevronUp size={14} color={iconColor} />
</Pressable>
<Pressable
accessibilityLabel="Next match"
disabled={controlDisabled}
onPress={onNext}
style={matchButtonStyle}
testID="pane-find-next"
>
<ChevronDown size={14} color={iconColor} />
</Pressable>
<Pressable
accessibilityLabel="Close find"
onPress={onClose}
style={styles.iconButton}
testID="pane-find-close"
>
<X size={14} color={iconColor} />
</Pressable>
</View>
);
}
function formatMatchState(matchState: PaneFindMatchState): string {
if (matchState.status === "matched") {
return `${matchState.current} / ${matchState.total}`;
}
if (matchState.status === "no-match") {
return "No matches";
}
if (matchState.status === "pending") {
return "Searching...";
}
return "0 / 0";
}
const styles = StyleSheet.create((theme) => ({
container: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
gap: 6,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
paddingHorizontal: 8,
paddingVertical: 4,
},
label: {
color: theme.colors.foregroundMuted,
fontSize: 12,
fontWeight: "600",
},
input: {
flex: 1,
minWidth: 120,
height: 26,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
borderRadius: 6,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
fontSize: 13,
paddingHorizontal: 8,
paddingVertical: 3,
},
counter: {
minWidth: 64,
color: theme.colors.foregroundMuted,
fontSize: 12,
textAlign: "right",
},
iconButton: {
width: 26,
height: 26,
alignItems: "center",
justifyContent: "center",
borderRadius: 6,
},
iconButtonDisabled: {
opacity: 0.4,
},
}));
@@ -17,7 +17,7 @@ import {
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostRoute(serverId));
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
}, [router, serverId]);
return (
+382 -87
View File
@@ -1,5 +1,13 @@
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import {
Alert,
Pressable,
@@ -22,6 +30,9 @@ import {
ChevronDown,
Settings,
Server,
Network,
Workflow,
Boxes,
Keyboard,
Stethoscope,
Info,
@@ -72,6 +83,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { isElectronRuntime } from "@/desktop/host";
@@ -81,7 +93,12 @@ import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
import {
HostConnectionsPage,
HostDaemonPage,
HostOrchestrationPage,
HostProvidersPage,
} from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -90,8 +107,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildHostOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostRoute,
buildSettingsHostSectionRoute,
buildSettingsSectionRoute,
type HostSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -103,7 +121,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
@@ -123,6 +141,19 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "about", label: "About", icon: Info },
];
interface HostSectionItem {
id: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
}
const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", label: "Connections", icon: Network },
{ id: "orchestration", label: "Orchestration", icon: Workflow },
{ id: "providers", label: "Providers", icon: Boxes },
{ id: "daemon", label: "Daemon", icon: Server },
];
// ---------------------------------------------------------------------------
// Theme helpers (General section)
// ---------------------------------------------------------------------------
@@ -709,6 +740,26 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
);
}
/**
* Local daemon first, then remaining hosts in their existing order. Lets the
* picker and the active-host resolver agree on a stable "first" host.
*/
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
return useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
}
interface SidebarSectionButtonProps {
itemId: SettingsSectionSlug;
label: string;
@@ -751,6 +802,49 @@ function SidebarSectionButton({
);
}
interface SidebarHostSectionButtonProps {
itemId: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
isSelected: boolean;
onSelect: (section: HostSectionSlug) => void;
}
function SidebarHostSectionButton({
itemId,
label,
icon: IconComponent,
isSelected,
onSelect,
}: SidebarHostSectionButtonProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(itemId);
}, [onSelect, itemId]);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
testID={`settings-host-section-${itemId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
@@ -782,83 +876,213 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
);
}
interface SidebarHostItemProps {
// Sentinel option id for the "Add host" row appended to the picker list.
const ADD_HOST_OPTION_ID = "__add_host__";
interface HostPickerOptionProps {
serverId: string;
label: string;
isSelected: boolean;
isLocal: boolean;
onSelect: (serverId: string) => void;
selected: boolean;
active: boolean;
onPress: () => void;
}
function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) {
function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
}: HostPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(serverId);
}, [onSelect, serverId]);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
const leadingSlot = useMemo(
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
testID={`settings-host-entry-${serverId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<Server
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
{isLocal ? (
// The local host carries a "Local" marker; the active host is conveyed by the
// row's selected check, so both can coexist on one row.
const trailingSlot = useMemo(
() =>
isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
Local
</Text>
) : null}
</Pressable>
) : undefined,
[isLocal],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={`settings-host-picker-item-${serverId}`}
/>
);
}
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const leadingSlot = useMemo(
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Add host"
leadingSlot={leadingSlot}
active={active}
onPress={onPress}
testID="settings-add-host"
/>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
localServerId: string | null;
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
}
/**
* Scopes the four host sections to a host. Reuses the canonical sidebar host
* switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a
* <Combobox>. The local host is listed first and tagged "Local"; an "Add host"
* row is always reachable from the list — even with a single host.
*/
function HostPicker({
activeServerId,
sortedHosts,
localServerId,
onSelectHost,
onAddHost,
}: HostPickerProps) {
const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const activeHost =
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
const options = useMemo<ComboboxOption[]>(() => {
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }];
}, [sortedHosts]);
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost();
return;
}
onSelectHost(id);
},
[onAddHost, onSelectHost],
);
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => {
if (option.id === ADD_HOST_OPTION_ID) {
return <AddHostOption active={active} onPress={onPress} />;
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={localServerId !== null && option.id === localServerId}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[localServerId],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
sidebarStyles.pickerTrigger,
hovered && sidebarStyles.pickerTriggerHovered,
],
[],
);
return (
<>
<Pressable
ref={triggerRef}
style={triggerStyle}
onPress={handleOpen}
accessibilityRole="button"
accessibilityLabel="Switch host"
testID="settings-host-picker"
>
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? "Host"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={options}
value={activeServerId ?? ""}
onSelect={handleSelect}
renderOption={renderOption}
searchable={false}
title="Switch host"
desktopMinWidth={240}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
/>
</>
);
}
interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHostSection: (section: HostSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
activeHostServerId: string | null;
layout: "desktop" | "mobile";
}
function SettingsSidebar({
view,
onSelectSection,
onSelectHostSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
activeHostServerId,
layout,
}: SettingsSidebarProps) {
const { theme } = useUnistyles();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
const sortedHosts = useSortedHosts(hosts, localServerId);
const hasHosts = sortedHosts.length > 0;
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
@@ -872,7 +1096,7 @@ function SettingsSidebar({
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const selectedHostSection = view.kind === "host" ? view.section : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
@@ -893,6 +1117,7 @@ function SettingsSidebar({
/>
) : null}
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>App</Text>
{items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton
@@ -909,30 +1134,43 @@ function SettingsSidebar({
))}
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
{sortedHosts.map((host) => (
<SidebarHostItem
key={host.serverId}
serverId={host.serverId}
label={host.label}
isSelected={selectedServerId === host.serverId}
isLocal={localServerId !== null && host.serverId === localServerId}
onSelect={onSelectHost}
{hasHosts ? (
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>Host</Text>
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
localServerId={localServerId}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
/>
))}
<Pressable
accessibilityRole="button"
accessibilityLabel="Add host"
onPress={onAddHost}
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
Add host
</Text>
</Pressable>
</View>
{HOST_SECTION_ITEMS.map((item) => (
<SidebarHostSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedHostSection === item.id}
onSelect={onSelectHostSection}
/>
))}
</View>
) : (
<View style={sidebarStyles.list}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Add host"
onPress={onAddHost}
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
Add host
</Text>
</Pressable>
</View>
)}
</View>
);
}
@@ -967,9 +1205,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[webScrollbarStyle],
);
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
// The host the four sections scope to: the host on the active view, otherwise
// the local daemon, otherwise the first available host.
const activeHostServerId = useMemo(() => {
if (view.kind === "host") return view.serverId;
return localServerId ?? sortedHosts[0]?.serverId ?? null;
}, [view, localServerId, sortedHosts]);
const handleThemeChange = useCallback(
(nextTheme: AppSettings["theme"]) => {
void updateSettings({ theme: nextTheme });
@@ -1055,7 +1302,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostRoute(serverId);
const target = buildSettingsHostSectionRoute(serverId, "connections");
if (isCompactLayout) {
router.push(target);
} else {
@@ -1077,16 +1324,34 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
// Picker: swap the host but keep the section the user is already looking at.
const handleSelectHost = useCallback(
(serverId: string) => {
const target = buildSettingsHostRoute(serverId);
const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
const target = buildSettingsHostSectionRoute(serverId, section);
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
},
[isCompactLayout, router],
[isCompactLayout, router, view],
);
const handleSelectHostSection = useCallback(
(section: HostSectionSlug) => {
if (!activeHostServerId) {
handleAddHost();
return;
}
const target = buildSettingsHostSectionRoute(activeHostServerId, section);
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
},
[activeHostServerId, handleAddHost, isCompactLayout, router],
);
const handleSelectProjects = useCallback(() => {
@@ -1140,13 +1405,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
titleAccessory?: ReactNode;
} | null => {
if (view.kind === "host") {
const host = hosts.find((h) => h.serverId === view.serverId);
if (!host) return null;
return {
title: host.label,
Icon: Server,
titleAccessory: <HostRenameButton host={host} />,
};
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null;
return { title: item.label, Icon: item.icon };
}
if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
@@ -1161,7 +1422,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const content = (() => {
if (view.kind === "host") {
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "orchestration":
return <HostOrchestrationPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "daemon":
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
@@ -1251,10 +1521,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="mobile"
/>
</ScrollView>
@@ -1293,10 +1565,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="desktop"
/>
<View style={desktopStyles.contentPane}>
@@ -1448,6 +1722,13 @@ const sidebarStyles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
gap: theme.spacing[1],
},
groupLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: "row",
alignItems: "center",
@@ -1472,11 +1753,25 @@ const sidebarStyles = StyleSheet.create((theme) => ({
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
pickerTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
borderRadius: theme.borderRadius.lg,
},
pickerTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
pickerTriggerLabel: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
}));
+139 -62
View File
@@ -74,25 +74,30 @@ function formatDaemonVersionBadge(version: string | null): string | null {
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
export interface HostPageProps {
serverId: string;
onHostRemoved?: () => void;
function useHostProfile(serverId: string): HostProfile | null {
const daemons = useHosts();
return daemons.find((entry) => entry.serverId === serverId) ?? null;
}
export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
const daemons = useHosts();
const host = daemons.find((entry) => entry.serverId === serverId) ?? null;
function HostNotFound() {
return (
<View>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Host not found</Text>
</View>
</View>
);
}
function HostStatusBadges({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const daemonVersion = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
let statusColor: string;
@@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const statusPillStyle = useMemo(
() => [styles.statusPill, { backgroundColor: statusPillBg }],
@@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
);
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
if (!host) {
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Host not found</Text>
</View>
return (
<View style={styles.identityBadges} testID="host-page-identity">
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
);
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
);
}
function HostConnectionError({ serverId }: { serverId: string }) {
const snapshot = useHostRuntimeSnapshot(serverId);
const lastError = snapshot?.lastError ?? null;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
if (!connectionError) return null;
return <Text style={styles.errorText}>{connectionError}</Text>;
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={styles.identityBadges} testID="host-page-identity">
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
{connectionError ? <Text style={styles.errorText}>{connectionError}</Text> : null}
<View>
<HostConnectionError serverId={serverId} />
<ConnectionsSection host={host} />
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
</View>
);
}
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} />
export function HostOrchestrationPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
{isConnected ? (
<SettingsSection title="Orchestration">
<InjectPaseoToolsCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
</SettingsSection>
) : (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text>
</View>
)}
</View>
);
}
export function HostProvidersPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<ProvidersSection serverId={serverId} />
</View>
);
}
export function HostDaemonPage({
serverId,
onHostRemoved,
}: {
serverId: string;
onHostRemoved?: () => void;
}) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<View style={styles.daemonHeader}>
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
{host.label}
</Text>
<HostRenameButton host={host} />
</View>
<HostStatusBadges serverId={serverId} />
{isLocalDaemon ? <LocalDaemonSection /> : null}
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
</View>
@@ -337,7 +419,7 @@ function ConnectionRow({
if (latencyLoading) return "...";
if (latencyError) return "Timeout";
if (latencyMs != null) return formatLatency(latencyMs);
return "\u2014";
return "—";
})();
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
@@ -378,23 +460,6 @@ function ConnectionRow({
);
}
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
return (
<>
<SettingsSection title="Daemon settings">
<InjectPaseoToolsCard serverId={host.serverId} />
<AppendSystemPromptCard serverId={host.serverId} />
</SettingsSection>
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
{isLocalDaemon ? <LocalDaemonSection /> : null}
</>
);
}
const delay = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
@@ -612,7 +677,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>System prompt</Text>
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
</View>
<Button
variant="outline"
@@ -803,6 +868,18 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
daemonHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginBottom: theme.spacing[4],
},
daemonHeaderLabel: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
identityBadges: {
flexDirection: "row",
alignItems: "center",
@@ -113,6 +113,7 @@ describe("WorkspacePaneContent", () => {
isInteractive: false,
focusPane: expect.any(Function),
});
expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull();
expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
@@ -1,4 +1,4 @@
import React, { useMemo, type ComponentType } from "react";
import React, { useEffect, useMemo, type ComponentType } from "react";
import invariant from "tiny-invariant";
import {
createPaneFocusContextValue,
@@ -10,6 +10,11 @@ import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
export interface WorkspacePaneContentModel {
key: string;
@@ -19,6 +24,7 @@ export interface WorkspacePaneContentModel {
export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -30,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput {
export function buildWorkspacePaneContentModel({
tab,
paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
@@ -41,12 +48,20 @@ export function buildWorkspacePaneContentModel({
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const paneInstanceId = paneId
? createPaneFindPaneId({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
})
: null;
return {
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
Component: registration.component,
paneContextValue: {
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneInstanceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
@@ -81,6 +96,16 @@ export function WorkspacePaneContent({
}),
[isPaneFocused, isWorkspaceFocused, onFocusPane],
);
useEffect(() => {
if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) {
return;
}
const paneInstanceId = paneContextValue.paneInstanceId;
setActivePaneFindPaneId(paneInstanceId);
return () => {
clearActivePaneFindPaneId(paneInstanceId);
};
}, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]);
return (
<PaneProvider value={paneContextValue}>
@@ -2465,7 +2465,7 @@ function WorkspaceScreenContent({
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await client.fetchAgentTimeline(agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
...(currentCursor
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
@@ -2827,6 +2827,7 @@ function WorkspaceScreenContent({
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
paneId: input.paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab: (target) => {
@@ -26,6 +26,16 @@ vi.mock("@xterm/addon-ligatures/lib/addon-ligatures.mjs", () => ({
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class SearchAddon {
findNext(): boolean {
return false;
}
findPrevious(): boolean {
return false;
}
clearDecorations(): void {}
onDidChangeResults(): { dispose: () => void } {
return { dispose: () => {} };
}
dispose(): void {}
},
}));
@@ -91,6 +101,15 @@ interface StubTerminal {
cols?: number;
}
interface StubSearchAddon {
findNext: (term: string, options?: unknown) => boolean;
findPrevious: (term: string, options?: unknown) => boolean;
clearDecorations: () => void;
onDidChangeResults: (listener: (event: { resultIndex: number; resultCount: number }) => void) => {
dispose: () => void;
};
}
function createRuntimeWithTerminal(): {
runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & {
@@ -145,6 +164,55 @@ function decodeTerminalOutput(data: string | Uint8Array): string {
return new TextDecoder().decode(data);
}
function createRuntimeWithSearchAddon(input?: {
findNextResult?: boolean;
findPreviousResult?: boolean;
}): {
runtime: TerminalEmulatorRuntime;
searchAddon: StubSearchAddon;
calls: {
findNext: Array<{ term: string; options: unknown }>;
findPrevious: Array<{ term: string; options: unknown }>;
clearDecorations: number;
subscribedListeners: Array<(event: { resultIndex: number; resultCount: number }) => void>;
disposeSubscription: number;
};
} {
const runtime = new TerminalEmulatorRuntime();
const calls = {
findNext: [] as Array<{ term: string; options: unknown }>,
findPrevious: [] as Array<{ term: string; options: unknown }>,
clearDecorations: 0,
subscribedListeners: [] as Array<(event: { resultIndex: number; resultCount: number }) => void>,
disposeSubscription: 0,
};
const searchAddon: StubSearchAddon = {
findNext: (term, options) => {
calls.findNext.push({ term, options });
return input?.findNextResult ?? true;
},
findPrevious: (term, options) => {
calls.findPrevious.push({ term, options });
return input?.findPreviousResult ?? true;
},
clearDecorations: () => {
calls.clearDecorations += 1;
},
onDidChangeResults: (listener) => {
calls.subscribedListeners.push(listener);
return {
dispose: () => {
calls.disposeSubscription += 1;
},
};
},
};
(runtime as unknown as { searchAddon: StubSearchAddon }).searchAddon = searchAddon;
return { runtime, searchAddon, calls };
}
describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
@@ -418,4 +486,71 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).not.toHaveBeenCalled();
});
it("delegates find navigation and clearing to the stored xterm search addon", () => {
const { runtime, calls } = createRuntimeWithSearchAddon({
findNextResult: true,
findPreviousResult: false,
});
expect(runtime.findNext({ query: "needle" })).toBe(true);
expect(runtime.findPrevious({ query: "needle" })).toBe(false);
runtime.clearFindDecorations();
expect(calls.findNext).toEqual([
{
term: "needle",
options: {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
},
},
]);
expect(calls.findPrevious).toEqual([
{
term: "needle",
options: {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
},
},
]);
expect(calls.clearDecorations).toBe(1);
});
it("forwards xterm search result changes and disposes the subscription", () => {
const { runtime, calls } = createRuntimeWithSearchAddon();
const events: Array<{ resultIndex: number; resultCount: number }> = [];
const unsubscribe = runtime.onFindResultsChanged((event) => {
events.push(event);
});
calls.subscribedListeners[0]?.({ resultIndex: 2, resultCount: 5 });
unsubscribe();
expect(events).toEqual([{ resultIndex: 2, resultCount: 5 }]);
expect(calls.disposeSubscription).toBe(1);
});
it("keeps find methods inert before the search addon is mounted", () => {
const runtime = new TerminalEmulatorRuntime();
const listener = vi.fn();
expect(runtime.findNext({ query: "needle" })).toBe(false);
expect(runtime.findPrevious({ query: "needle" })).toBe(false);
runtime.clearFindDecorations();
runtime.onFindResultsChanged(listener)();
expect(listener).not.toHaveBeenCalled();
});
});
@@ -2,11 +2,12 @@ import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
import type { ISearchOptions, ISearchResultChangeEvent } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme } from "@xterm/xterm";
import { Terminal, type IDisposable, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
type TerminalInputModeState,
@@ -61,6 +62,12 @@ export interface TerminalEmulatorRuntimeCallbacks {
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
export type TerminalFindResultChangeEvent = ISearchResultChangeEvent;
export interface TerminalFindQueryInput {
query: string;
}
interface TerminalEmulatorRuntimeDisposables {
disposeInput: () => void;
disconnectResizeObserver: () => void;
@@ -128,6 +135,16 @@ function prependTerminalOutput(
return output;
}
const TERMINAL_FIND_SEARCH_OPTIONS: ISearchOptions = {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
};
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
"JetBrains Mono",
@@ -164,6 +181,7 @@ export class TerminalEmulatorRuntime {
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
private fitAndEmitResize: ((force: boolean) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -223,6 +241,7 @@ export class TerminalEmulatorRuntime {
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon({ highlightLimit: 20_000 });
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null;
let imageAddon: ImageAddon | null = null;
@@ -245,7 +264,7 @@ export class TerminalEmulatorRuntime {
},
}),
);
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(searchAddon);
terminal.loadAddon(new ClipboardAddon());
try {
terminal.loadAddon(new LigaturesAddon());
@@ -334,6 +353,7 @@ export class TerminalEmulatorRuntime {
this.terminal = terminal;
this.fitAddon = fitAddon;
this.searchAddon = searchAddon;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => {
@@ -687,6 +707,25 @@ export class TerminalEmulatorRuntime {
this.terminal?.blur();
}
findNext(input: TerminalFindQueryInput): boolean {
return this.searchAddon?.findNext(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false;
}
findPrevious(input: TerminalFindQueryInput): boolean {
return this.searchAddon?.findPrevious(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false;
}
clearFindDecorations(): void {
this.searchAddon?.clearDecorations();
}
onFindResultsChanged(listener: (event: TerminalFindResultChangeEvent) => void): () => void {
const disposable: IDisposable | undefined = this.searchAddon?.onDidChangeResults(listener);
return () => {
disposable?.dispose();
};
}
private refreshVisibleRows(): void {
const terminal = this.terminal;
if (!terminal || terminal.rows <= 0) {
@@ -739,6 +778,7 @@ export class TerminalEmulatorRuntime {
}
this.terminal = null;
this.fitAddon = null;
this.searchAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
this.themeBackgroundElements = [];
@@ -547,6 +547,153 @@ describe("processTimelineResponse", () => {
});
});
it("hydrates a fetched in-progress tool call as one item and streams the next update on top", () => {
const fetched = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "tail",
payload: {
...baseTimelineInput.payload,
direction: "tail",
epoch: "epoch-1",
startCursor: { seq: 10 },
endCursor: { seq: 250 },
entries: [
{
...makeToolCallTimelineEntry(10, "call-1", "running", {
type: "read",
filePath: "/tmp/example.ts",
}),
seqEnd: 250,
sourceSeqRanges: [
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
expect(getAgentToolCalls(fetched.tail)).toHaveLength(1);
expect(fetched.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 250 });
const streamed = processAgentStreamEvent({
...baseStreamInput,
currentTail: fetched.tail,
currentHead: fetched.head,
currentCursor: fetched.cursor ?? undefined,
seq: 251,
epoch: "epoch-1",
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId: "call-1",
name: "Read",
status: "completed",
detail: {
type: "read",
filePath: "/tmp/example.ts",
},
error: null,
},
} as AgentStreamEventPayload,
});
const tools = getAgentToolCalls(streamed.tail);
expect(tools).toHaveLength(1);
expect(tools[0]?.payload.data.status).toBe("completed");
expect(streamed.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 251 });
});
it("accepts an after-page projected tool update whose item started before the cursor", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 10,
endSeq: 249,
};
const runningTool = hydrateStreamState(
[
{
event: makeToolCallTimelineEvent("call-1"),
timestamp: new Date(1010),
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: runningTool,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 250 },
endCursor: { seq: 250 },
entries: [
{
...makeToolCallTimelineEntry(10, "call-1", "completed", {
type: "read",
filePath: "/tmp/example.ts",
}),
seqEnd: 250,
sourceSeqRanges: [
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
const tools = getAgentToolCalls(result.tail);
expect(catchUp).toBeUndefined();
expect(tools).toHaveLength(1);
expect(tools[0]?.payload.data.status).toBe("completed");
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 250 });
});
it("replaces an active assistant head when after-page returns a full projected assistant item", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
const currentHead = [makeAssistantItem("ABC")];
const result = processTimelineResponse({
...baseTimelineInput,
currentHead,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 4 },
endCursor: { seq: 5 },
entries: [
{
...makeTimelineEntry(1, "ABCDE"),
seqEnd: 5,
sourceSeqRanges: [{ startSeq: 1, endSeq: 5 }],
collapsed: ["assistant_merge"],
},
],
},
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getAssistantTexts(result.head)).toEqual(["ABCDE"]);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 5 });
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
@@ -53,9 +53,16 @@ type SessionTimelineSeqCursor =
type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
interface TimelineSeqRange {
startSeq: number;
endSeq: number;
}
interface TimelineResponseEntry {
seqStart: number;
seqEnd: number;
sourceSeqRanges?: TimelineSeqRange[];
collapsed?: string[];
provider: string;
item: Record<string, unknown>;
timestamp: string;
@@ -96,6 +103,7 @@ export interface ProcessTimelineResponseOutput {
interface TimelineUnit {
seq: number;
seqEnd: number;
sourceSeqRanges: TimelineSeqRange[];
event: AgentStreamEventPayload;
timestamp: Date;
}
@@ -376,44 +384,56 @@ function acceptIncrementalTimelineUnits(args: {
currentCursor: TimelineCursor | undefined;
}): IncrementalAcceptResult {
const { timelineUnits, payload, currentCursor } = args;
const acceptedUnits: TimelineUnit[] = [];
let cursor: TimelineCursor | undefined = currentCursor;
let gapCursor: { epoch: string; endSeq: number } | null = null;
const firstUnit = timelineUnits[0];
const lastUnit = timelineUnits[timelineUnits.length - 1];
const responseStartSeq = payload.startCursor?.seq ?? firstUnit?.seq;
const responseEndSeq = payload.endCursor?.seq ?? lastUnit?.seqEnd;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
epoch: payload.epoch,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale") {
if (cursor && unit.seqEnd > cursor.endSeq) {
gapCursor = { epoch: cursor.epoch, endSeq: cursor.endSeq };
break;
}
continue;
}
if (decision === "drop_epoch") {
continue;
}
acceptedUnits.push(unit);
if (decision === "init") {
cursor = { epoch: payload.epoch, startSeq: unit.seq, endSeq: unit.seqEnd };
continue;
}
if (!cursor) {
continue;
}
cursor = { ...cursor, endSeq: unit.seqEnd };
if (responseStartSeq === undefined || responseEndSeq === undefined) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
return { acceptedUnits, cursor, gapCursor };
if (!currentCursor) {
return {
acceptedUnits: timelineUnits,
cursor: { epoch: payload.epoch, startSeq: responseStartSeq, endSeq: responseEndSeq },
gapCursor: null,
};
}
if (currentCursor.epoch !== payload.epoch) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
if (
(!payload.startCursor || !payload.endCursor) &&
responseStartSeq <= currentCursor.endSeq &&
responseEndSeq > currentCursor.endSeq
) {
return {
acceptedUnits: [],
cursor: currentCursor,
gapCursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
};
}
if (responseEndSeq <= currentCursor.endSeq) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
if (responseStartSeq > currentCursor.endSeq + 1) {
return {
acceptedUnits: [],
cursor: currentCursor,
gapCursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
};
}
return {
acceptedUnits: timelineUnits,
cursor: { ...currentCursor, endSeq: responseEndSeq },
gapCursor: null,
};
}
function acceptOlderTimelineUnits(args: {
@@ -426,16 +446,21 @@ function acceptOlderTimelineUnits(args: {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
const acceptedUnits = timelineUnits.filter((unit) => unit.seqEnd < currentCursor.startSeq);
if (acceptedUnits.length === 0) {
return { acceptedUnits, cursor: currentCursor, gapCursor: null };
const firstUnit = timelineUnits[0];
const lastUnit = timelineUnits[timelineUnits.length - 1];
const responseStartSeq = payload.startCursor?.seq ?? firstUnit?.seq;
const responseEndSeq = payload.endCursor?.seq ?? lastUnit?.seqEnd;
if (
responseStartSeq === undefined ||
responseEndSeq === undefined ||
responseEndSeq >= currentCursor.startSeq
) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
const firstAccepted = acceptedUnits[0];
const startSeq = payload.startCursor?.seq ?? firstAccepted?.seq ?? currentCursor.startSeq;
return {
acceptedUnits,
cursor: { ...currentCursor, startSeq },
acceptedUnits: timelineUnits,
cursor: { ...currentCursor, startSeq: responseStartSeq },
gapCursor: null,
};
}
@@ -480,6 +505,32 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
];
}
function replaceLiveAssistantWithProjectedText(params: {
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
}): StreamItem[] | null {
const { head, event, timestamp } = params;
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
return null;
}
const index = head.findLastIndex((item) => item.kind === "assistant_message");
const current = head[index];
if (!current || current.kind !== "assistant_message") {
return null;
}
if (!event.item.text.startsWith(current.text)) {
return null;
}
const next = [...head];
next[index] = {
...current,
text: event.item.text,
timestamp,
};
return next;
}
function applyTimelineIncrementalPath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
@@ -523,6 +574,15 @@ function applyTimelineIncrementalPath(args: {
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
} else if (currentHead.length > 0) {
for (const { event, timestamp } of acceptedUnits) {
const replacedHead = replaceLiveAssistantWithProjectedText({
head: nextHead,
event,
timestamp,
});
if (replacedHead) {
nextHead = replacedHead;
continue;
}
const applied = applyStreamEvent({
tail: nextTail,
head: nextHead,
@@ -597,6 +657,10 @@ export function processTimelineResponse(
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart,
seqEnd: entry.seqEnd,
sourceSeqRanges:
entry.sourceSeqRanges && entry.sourceSeqRanges.length > 0
? entry.sourceSeqRanges
: [{ startSeq: entry.seqStart, endSeq: entry.seqEnd }],
event: {
type: "timeline",
provider: entry.provider,
@@ -1,2 +1,3 @@
// Count is projected timeline items, not delta chunks. The daemon's `selectTimelineWindowByProjectedLimit` interprets this against canonical entries: `assistant_merge`, `reasoning_merge`, and `tool_lifecycle`. Do not confuse this with raw stream deltas.
// Count is projected timeline items, not delta chunks. Fetch responses never return
// tool lifecycle deltas; `sourceSeqRanges` maps projected items back to source seqs.
export const TIMELINE_FETCH_PAGE_SIZE = 100;
@@ -18,7 +18,7 @@ describe("timeline sync planning", () => {
expect(plan).toEqual({
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -32,7 +32,7 @@ describe("timeline sync planning", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -45,7 +45,7 @@ describe("timeline sync planning", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 100 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -55,7 +55,7 @@ describe("timeline sync planning", () => {
expect(plan).toEqual({
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -66,7 +66,7 @@ describe("timeline sync planning", () => {
direction: "before",
cursor: { epoch: "epoch-1", seq: 25 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -82,7 +82,7 @@ describe("timeline sync planning", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 200 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
+19 -19
View File
@@ -11,39 +11,39 @@ export interface AgentTimelineCursorRange {
endSeq: number;
}
export interface CanonicalTimelineTailFetchPlan {
export interface ProjectedTimelineTailFetchPlan {
direction: "tail";
limit: number;
projection: "canonical";
projection: "projected";
}
export interface CanonicalTimelineAfterFetchPlan {
export interface ProjectedTimelineAfterFetchPlan {
direction: "after";
cursor: TimelineSyncCursor;
limit: number;
projection: "canonical";
projection: "projected";
}
export interface CanonicalTimelineBeforeFetchPlan {
export interface ProjectedTimelineBeforeFetchPlan {
direction: "before";
cursor: TimelineSyncCursor;
limit: number;
projection: "canonical";
projection: "projected";
}
export type CanonicalTimelineFetchPlan =
| CanonicalTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan
| CanonicalTimelineBeforeFetchPlan;
export type ProjectedTimelineFetchPlan =
| ProjectedTimelineTailFetchPlan
| ProjectedTimelineAfterFetchPlan
| ProjectedTimelineBeforeFetchPlan;
export type CanonicalTimelineForwardFetchPlan =
| CanonicalTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan;
export type ProjectedTimelineForwardFetchPlan =
| ProjectedTimelineTailFetchPlan
| ProjectedTimelineAfterFetchPlan;
export function planInitialAgentTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined;
hasAuthoritativeHistory: boolean;
}): CanonicalTimelineForwardFetchPlan {
}): ProjectedTimelineForwardFetchPlan {
if (input.hasAuthoritativeHistory && input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
}
@@ -53,7 +53,7 @@ export function planInitialAgentTimelineSync(input: {
export function planResumeTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined;
}): CanonicalTimelineForwardFetchPlan {
}): ProjectedTimelineForwardFetchPlan {
if (input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
}
@@ -66,7 +66,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
direction: "after",
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
} as const;
}
@@ -74,7 +74,7 @@ export function planTimelineTailFetch() {
return {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
} as const;
}
@@ -83,7 +83,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
direction: "before",
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
} as const;
}
@@ -92,7 +92,7 @@ export function planTimelineCatchUpFollowUp(input: {
hasNewer: boolean;
endCursor: TimelineSyncCursor | null;
error: string | null;
}): CanonicalTimelineAfterFetchPlan | null {
}): ProjectedTimelineAfterFetchPlan | null {
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
return null;
}
+16
View File
@@ -390,6 +390,14 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
}
export const HOST_SECTION_SLUGS = ["connections", "orchestration", "providers", "daemon"] as const;
export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number];
export function isHostSectionSlug(value: string): value is HostSectionSlug {
return (HOST_SECTION_SLUGS as readonly string[]).includes(value);
}
export function buildSettingsRoute() {
return "/settings" as const;
}
@@ -406,6 +414,14 @@ export function buildSettingsHostRoute(serverId: string) {
return `/settings/hosts/${encodeSegment(normalized)}` as const;
}
export function buildSettingsHostSectionRoute(serverId: string, section: HostSectionSlug) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
throw new Error("buildSettingsHostSectionRoute requires a non-empty serverId");
}
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
}
export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -25,9 +25,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/server": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/server": "0.1.87",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -33,8 +33,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"zod": "^3.23.8"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -1,16 +1,71 @@
import { webContents as allWebContents, type WebContents } from "electron";
export const BROWSER_FOUND_IN_PAGE_EVENT = "paseo:event:browser-found-in-page";
const browserIdsByWebContentsId = new Map<number, string>();
const webContentsIdsByBrowserId = new Map<string, number>();
const ownerWebContentsIdsByBrowserId = new Map<string, number>();
const activeFindBrowserIdsByOwnerWebContentsId = new Map<number, string>();
const ownerFoundInPageListenerWebContentsIds = new Set<number>();
let workspaceActiveBrowserId: string | null = null;
export function listRegisteredPaseoBrowserIds(): string[] {
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
}
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
function ensureOwnerFoundInPageListener(ownerContents: WebContents): void {
if (ownerFoundInPageListenerWebContentsIds.has(ownerContents.id)) {
return;
}
ownerFoundInPageListenerWebContentsIds.add(ownerContents.id);
const handleFoundInPage = (_event: Electron.Event, result: Electron.Result): void => {
const browserId = activeFindBrowserIdsByOwnerWebContentsId.get(ownerContents.id);
if (!browserId || ownerContents.isDestroyed()) {
return;
}
ownerContents.send(BROWSER_FOUND_IN_PAGE_EVENT, {
browserId,
requestId: result.requestId,
activeMatchOrdinal: result.activeMatchOrdinal,
matches: result.matches,
finalUpdate: result.finalUpdate,
});
if (result.finalUpdate) {
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id);
}
};
ownerContents.on("found-in-page", handleFoundInPage);
ownerContents.once("destroyed", () => {
ownerContents.removeListener("found-in-page", handleFoundInPage);
ownerFoundInPageListenerWebContentsIds.delete(ownerContents.id);
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id);
});
}
export function registerPaseoBrowserWebContents(
contents: WebContents,
browserId: string,
ownerContents: WebContents,
): void {
browserIdsByWebContentsId.set(contents.id, browserId);
webContentsIdsByBrowserId.set(browserId, contents.id);
if (!ownerContents.isDestroyed()) {
ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id);
ensureOwnerFoundInPageListener(ownerContents);
}
contents.once("destroyed", () => {
browserIdsByWebContentsId.delete(contents.id);
if (webContentsIdsByBrowserId.get(browserId) === contents.id) {
webContentsIdsByBrowserId.delete(browserId);
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
ownerWebContentsIdsByBrowserId.delete(browserId);
if (
ownerContentsId &&
activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId
) {
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId);
}
}
if (workspaceActiveBrowserId === browserId) {
workspaceActiveBrowserId = null;
}
@@ -29,14 +84,35 @@ export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void
}
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) {
if (registeredBrowserId !== browserId) continue;
const contents = allWebContents.fromId(contentsId);
if (contents && !contents.isDestroyed()) {
return contents;
}
const contentsId = webContentsIdsByBrowserId.get(browserId);
if (!contentsId) {
return null;
}
const contents = allWebContents.fromId(contentsId);
return contents && !contents.isDestroyed() ? contents : null;
}
export function setActivePaseoBrowserFind(browserId: string): void {
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
if (!ownerContentsId) {
return;
}
const ownerContents = allWebContents.fromId(ownerContentsId);
if (!ownerContents || ownerContents.isDestroyed()) {
return;
}
ensureOwnerFoundInPageListener(ownerContents);
activeFindBrowserIdsByOwnerWebContentsId.set(ownerContents.id, browserId);
}
export function clearActivePaseoBrowserFind(browserId: string): void {
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
if (!ownerContentsId) {
return;
}
if (activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId) {
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId);
}
return null;
}
export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
+45 -1
View File
@@ -33,10 +33,12 @@ import {
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
clearActivePaseoBrowserFind,
getPaseoBrowserIdForWebContents,
getPaseoBrowserWebContents,
listRegisteredPaseoBrowserIds,
registerPaseoBrowserWebContents,
setActivePaseoBrowserFind,
setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
@@ -310,6 +312,48 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
await session.fromPartition(partition).clearStorageData();
});
ipcMain.handle(
"paseo:browser:find-in-page",
(_event, browserId: unknown, text: unknown, options: unknown): number | null => {
if (typeof browserId !== "string" || typeof text !== "string" || text.length === 0) {
return null;
}
const contents = getPaseoBrowserWebContents(browserId);
if (!contents) {
return null;
}
setActivePaseoBrowserFind(browserId);
const inputOptions =
options && typeof options === "object"
? (options as { forward?: unknown; findNext?: unknown; matchCase?: unknown })
: {};
return contents.findInPage(text, {
forward: inputOptions.forward !== false,
findNext: inputOptions.findNext === true,
matchCase: inputOptions.matchCase === true,
});
},
);
ipcMain.handle(
"paseo:browser:stop-find-in-page",
(_event, browserId: unknown, action: unknown): null => {
if (typeof browserId !== "string") {
return null;
}
if (
action !== "clearSelection" &&
action !== "keepSelection" &&
action !== "activateSelection"
) {
return null;
}
getPaseoBrowserWebContents(browserId)?.stopFindInPage(action);
clearActivePaseoBrowserFind(browserId);
return null;
},
);
protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
@@ -431,7 +475,7 @@ async function createMainWindow(): Promise<void> {
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
const browserId = pendingBrowserWebviewIds.shift() ?? null;
if (browserId) {
registerPaseoBrowserWebContents(contents, browserId);
registerPaseoBrowserWebContents(contents, browserId, mainWindow.webContents);
log.info("[browser-webview] registered", {
browserId,
webContentsId: contents.id,
+34
View File
@@ -1,6 +1,14 @@
import { contextBridge, ipcRenderer, webUtils } from "electron";
type EventHandler = (payload: unknown) => void;
type BrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
interface BrowserFoundInPageResult {
requestId: number;
activeMatchOrdinal: number;
matches: number;
finalUpdate: boolean;
}
contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
@@ -69,5 +77,31 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
clearPartition: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
findInPage: (
browserId: string,
text: string,
options?: { forward?: boolean; findNext?: boolean; matchCase?: boolean },
) => ipcRenderer.invoke("paseo:browser:find-in-page", browserId, text, options),
stopFindInPage: (browserId: string, action: BrowserFindAction) =>
ipcRenderer.invoke("paseo:browser:stop-find-in-page", browserId, action),
onFoundInPage: (
browserId: string,
listener: (result: BrowserFoundInPageResult) => void,
): (() => void) => {
const ipcListener = (
_ipcEvent: Electron.IpcRendererEvent,
payload: BrowserFoundInPageResult & { browserId?: unknown },
) => {
if (payload?.browserId !== browserId) {
return;
}
const { browserId: _browserId, ...result } = payload;
listener(result);
};
ipcRenderer.on("paseo:event:browser-found-in-page", ipcListener);
return () => {
ipcRenderer.removeListener("paseo:event:browser-found-in-page", ipcListener);
};
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.86",
"version": "0.1.87",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.86",
"version": "0.1.87",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.86",
"version": "0.1.87",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -57,10 +57,10 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.86",
"@getpaseo/highlight": "0.1.86",
"@getpaseo/protocol": "0.1.86",
"@getpaseo/relay": "0.1.86",
"@getpaseo/client": "0.1.87",
"@getpaseo/highlight": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -1954,6 +1954,15 @@ function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): {
};
}
function isOpenCodeSessionTrackedByParent(
sessionId: string,
state: OpenCodeEventTranslationState,
): boolean {
return (
sessionId === state.sessionId || state.subAgentCallIdByChildSessionId?.has(sessionId) === true
);
}
function getOpenCodeSubAgentState(
callId: string,
state: OpenCodeEventTranslationState,
@@ -2438,7 +2447,7 @@ function appendOpenCodePermissionAsked(
state: OpenCodeEventTranslationState,
events: AgentStreamEvent[],
): void {
if (event.properties.sessionID !== state.sessionId) {
if (!isOpenCodeSessionTrackedByParent(event.properties.sessionID, state)) {
return;
}
const metadata = readOpenCodeRecord(event.properties.metadata);
@@ -387,6 +387,97 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("forwards permission requests from linked OpenCode subagent sessions", () => {
const state = createState();
translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "part-subagent",
sessionID: "session-1",
messageID: "message-1",
type: "tool",
tool: "task",
callID: "call-subagent",
state: {
status: "running",
input: {
subagent_type: "explore",
description: "Explore external config",
},
},
},
},
},
state,
);
translateOpenCodeEvent(
{
type: "session.created",
properties: {
sessionID: "child-session-1",
info: {
id: "child-session-1",
parentID: "session-1",
},
},
},
state,
);
const result = translateOpenCodeEvent(
{
type: "permission.asked",
properties: {
id: "perm-child-1",
sessionID: "child-session-1",
permission: "external_directory",
patterns: ["/Users/example/.config/nvim"],
metadata: {
reason: "Need to inspect the requested config directory",
},
},
},
state,
);
expect(result).toEqual([
{
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-child-1",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
description:
"Need to inspect the requested config directory - Scope: /Users/example/.config/nvim",
input: {
patterns: ["/Users/example/.config/nvim"],
metadata: {
reason: "Need to inspect the requested config directory",
},
},
detail: {
type: "unknown",
input: {
permission: "external_directory",
patterns: ["/Users/example/.config/nvim"],
metadata: {
reason: "Need to inspect the requested config directory",
},
},
output: null,
},
actions: openCodePermissionActions,
},
},
]);
});
it("emits usage_updated after step-finish parts", () => {
const state = createState();
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
} from "./timeline-projection.js";
@@ -436,58 +437,178 @@ describe("selectTimelineWindowByProjectedLimit", () => {
});
});
test("can enforce a hard projected limit when tool lifecycle collapsing is disabled", () => {
test("tail limit treats a repeated running tool call as one projected item", () => {
const rows: AgentTimelineRow[] = [
{
seq: 1,
timestamp: "2026-02-13T00:00:00.000Z",
...Array.from({ length: 6 }, (_, index) => ({
seq: index + 1,
timestamp: `2026-02-13T00:00:00.00${index}Z`,
item: { type: "assistant_message" as const, text: `old ${index}` },
})),
...Array.from({ length: 20 }, (_, index) => ({
seq: index + 7,
timestamp: `2026-02-13T00:00:01.0${index}Z`,
item: {
type: "tool_call",
type: "tool_call" as const,
callId: "call_1",
name: "shell",
status: "running",
status: "running" as const,
error: null,
detail: {
type: "unknown",
input: { cmd: "pwd" },
output: null,
type: "unknown" as const,
input: { cmd: "sleep 10" },
output: { progress: index },
},
},
},
{
seq: 2,
timestamp: "2026-02-13T00:00:00.100Z",
item: { type: "assistant_message", text: "work" },
},
{
seq: 3,
timestamp: "2026-02-13T00:00:00.200Z",
item: {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { cmd: "pwd" },
output: { stdout: "/tmp" },
},
},
},
})),
];
const selected = selectTimelineWindowByProjectedLimit({
rows,
direction: "tail",
limit: 1,
collapseToolLifecycle: false,
limit: 100,
});
expect(selected.minSeq).toBe(3);
expect(selected.maxSeq).toBe(3);
expect(selected.selectedRows.map((row) => row.seq)).toEqual([3]);
expect(selected.projectedEntries).toHaveLength(1);
expect(selected.projectedEntries[0]?.item.type).toBe("tool_call");
const tools = selected.projectedEntries.filter((entry) => entry.item.type === "tool_call");
expect(tools).toHaveLength(1);
expect(tools[0]?.collapsed).toContain("tool_lifecycle");
expect(selected.projectedEntries).toHaveLength(2);
});
});
describe("selectProjectedTimelinePage", () => {
function toolRow(seq: number, status: "running" | "completed"): AgentTimelineRow {
return {
seq,
timestamp: new Date(1000 + seq).toISOString(),
item: {
type: "tool_call",
callId: "call_1",
name: "shell",
status,
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: status === "completed" ? { stdout: "done" } : null,
},
},
};
}
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
const rows: AgentTimelineRow[] = [
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
...Array.from({ length: 120 }, (_, index) => toolRow(index + 2, "running")),
];
const page = selectProjectedTimelinePage({ rows, direction: "tail", limit: 100 });
expect(page.entries.map((entry) => entry.item.type)).toEqual(["user_message", "tool_call"]);
expect(page.entries[1]?.collapsed).toContain("tool_lifecycle");
expect(page.entries[1]?.sourceSeqRanges).toEqual([{ startSeq: 2, endSeq: 121 }]);
expect(page.startSeq).toBe(1);
expect(page.endSeq).toBe(121);
expect(page.hasNewer).toBe(false);
});
test("after page includes a full projected tool item when only its update is new", () => {
const rows: AgentTimelineRow[] = [
toolRow(10, "running"),
{
seq: 11,
timestamp: "2026-02-13T00:00:00.011Z",
item: { type: "assistant_message", text: "working" },
},
toolRow(250, "completed"),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 249,
limit: 100,
});
expect(page.entries).toHaveLength(1);
expect(page.entries[0]?.item.type).toBe("tool_call");
expect(page.entries[0]?.seqStart).toBe(10);
expect(page.entries[0]?.seqEnd).toBe(250);
expect(page.entries[0]?.sourceSeqRanges).toEqual([
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
]);
expect(page.startSeq).toBe(250);
expect(page.endSeq).toBe(250);
});
test("after page cursor advances only through contiguously covered seqs", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 498 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(500, "completed"),
...Array.from({ length: 101 }, (_, index) => ({
seq: index + 501,
timestamp: new Date(3000 + index).toISOString(),
item: { type: "user_message" as const, text: `later ${index + 501}` },
})),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 0,
limit: 100,
});
expect(page.entries[0]?.item.type).toBe("tool_call");
expect(
page.entries.some((entry) => entry.item.type === "user_message" && entry.seqStart === 101),
).toBe(false);
expect(page.endSeq).toBe(100);
expect(page.hasNewer).toBe(true);
});
test("before page includes a wide tool whose earlier source range is before the cursor", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 498 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(500, "completed"),
];
const page = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 500,
limit: 100,
});
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
expect(page.endSeq).toBeLessThan(500);
expect(page.hasOlder).toBe(true);
});
test("tail page includes a wide tool when its completion is the newest seq", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 499 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(501, "completed"),
];
const page = selectProjectedTimelinePage({ rows, direction: "tail", limit: 100 });
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
expect(page.endSeq).toBe(501);
});
});
@@ -28,6 +28,14 @@ interface ProjectedWindowSelection {
maxSeq: number | null;
}
export interface ProjectedTimelinePageSelection {
entries: TimelineProjectionEntry[];
startSeq: number | null;
endSeq: number | null;
hasOlder: boolean;
hasNewer: boolean;
}
function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] {
if (ranges.length === 0) {
return [{ startSeq: seq, endSeq: seq }];
@@ -266,15 +274,11 @@ export function selectTimelineWindowByProjectedLimit(input: {
rows: readonly AgentTimelineRow[];
direction: TimelineLimitDirection;
limit: number;
collapseToolLifecycle?: boolean;
}): ProjectedWindowSelection {
const { rows, direction } = input;
const limit = Math.max(0, Math.floor(input.limit));
const collapseTools = input.collapseToolLifecycle ?? true;
const canonical = makeCanonicalEntries(rows);
const projectedAll = mergeReasoningChunks(
mergeAssistantChunks(collapseTools ? collapseToolLifecycle(canonical) : canonical),
);
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseToolLifecycle(canonical)));
if (projectedAll.length === 0) {
return {
@@ -320,27 +324,25 @@ export function selectTimelineWindowByProjectedLimit(input: {
let { minSeq, maxSeq } = computeWindowBounds(projectedEntries);
let expandedEntries = projectedEntries;
if (collapseTools) {
// Expand to include any projected entries that overlap the selected
// canonical range. Tool lifecycle collapse can produce non-monotonic
// seqEnd values, which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
overlapping.length === expandedEntries.length &&
nextBounds.minSeq === minSeq &&
nextBounds.maxSeq === maxSeq
) {
expandedEntries = overlapping;
break;
}
// Expand to include any projected entries that overlap the selected canonical
// range. Tool lifecycle collapse can produce non-monotonic seqEnd values,
// which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
overlapping.length === expandedEntries.length &&
nextBounds.minSeq === minSeq &&
nextBounds.maxSeq === maxSeq
) {
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.maxSeq;
break;
}
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.maxSeq;
}
const selectedRows = rows.filter((row) => row.seq >= minSeq && row.seq <= maxSeq);
@@ -353,6 +355,94 @@ export function selectTimelineWindowByProjectedLimit(input: {
};
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
return { minSeq: first.seq, maxSeq: last.seq };
}
function selectEntriesOverlappingSeqRange(input: {
entries: readonly TimelineProjectionEntry[];
startSeq: number;
endSeq: number;
}): TimelineProjectionEntry[] {
return input.entries.filter(
(entry) => entry.seqStart <= input.endSeq && entry.seqEnd >= input.startSeq,
);
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (projectedAll.length === 0 || !bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
const selected = selectTimelineWindowByProjectedLimit({
rows: input.rows,
direction: "tail",
limit,
});
return {
entries: selected.projectedEntries,
startSeq: selected.minSeq,
endSeq: selected.maxSeq,
hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq,
hasNewer: false,
};
}
let startSeq: number;
let endSeq: number;
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
endSeq = limit === 0 ? bounds.maxSeq : Math.min(bounds.maxSeq, cursorSeq + limit);
} else {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
startSeq = limit === 0 ? bounds.minSeq : Math.max(bounds.minSeq, cursorSeq - limit);
}
if (startSeq > endSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: startSeq > bounds.minSeq,
hasNewer: endSeq < bounds.maxSeq,
};
}
const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq });
return {
entries,
startSeq,
endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: endSeq < bounds.maxSeq,
};
}
/**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -17,6 +17,7 @@ describe("daemon E2E - timeline window", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
await ctx.cleanup();
}, 60_000);
@@ -66,6 +67,10 @@ describe("daemon E2E - timeline window", () => {
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
const expected = "SECOND";
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: "next",
});
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
@@ -86,4 +91,271 @@ describe("daemon E2E - timeline window", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("timeline fetch returns one projected in-progress tool call instead of lifecycle deltas", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tool Projection Test",
modeId: "full-access",
});
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: "run the tool",
});
for (let index = 0; index < 120; index += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "running",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: { progress: index },
},
});
}
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
projection: "canonical",
});
const toolEntries = timeline.entries.filter((entry) => entry.item.type === "tool_call");
expect(timeline.projection).toBe("projected");
expect(timeline.entries.map((entry) => entry.item.type)).toEqual([
"user_message",
"tool_call",
]);
expect(toolEntries).toHaveLength(1);
expect(toolEntries[0]?.collapsed).toContain("tool_lifecycle");
expect(toolEntries[0]?.sourceSeqRanges).toEqual([{ startSeq: 2, endSeq: 121 }]);
expect(timeline.endCursor?.seq).toBe(121);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("after fetch returns the full projected tool item for a new lifecycle update", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tool Catch-up Test",
modeId: "full-access",
});
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "running",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: null,
},
});
for (let seq = 2; seq <= 249; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `background ${seq}`,
});
}
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: { stdout: "done" },
},
});
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
});
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch: baseline.epoch, seq: 249 },
limit: 100,
projection: "canonical",
});
expect(timeline.entries).toHaveLength(1);
expect(timeline.startCursor?.seq).toBe(250);
expect(timeline.endCursor?.seq).toBe(250);
expect(timeline.entries[0]?.seqStart).toBe(1);
expect(timeline.entries[0]?.seqEnd).toBe(250);
expect(timeline.entries[0]?.sourceSeqRanges).toEqual([
{ startSeq: 1, endSeq: 1 },
{ startSeq: 250, endSeq: 250 },
]);
expect(timeline.entries[0]?.item.type).toBe("tool_call");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("reset timeline fetch reports older history when the reset slice starts after window min", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Reset HasOlder Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
cursor: { epoch: "stale-epoch", seq: 600 },
limit: 200,
});
expect(timeline.reset).toBe(true);
expect(timeline.staleCursor).toBe(true);
expect(timeline.startCursor?.seq).toBeGreaterThan(timeline.window.minSeq);
expect(timeline.hasOlder).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("tail fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tail Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(501);
expect(timeline.endCursor?.seq).toBe(600);
expect(timeline.hasOlder).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("after fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline After Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 300 },
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(301);
expect(timeline.endCursor?.seq).toBe(400);
expect(timeline.hasNewer).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("before fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Before Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "before",
cursor: { epoch, seq: 501 },
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(401);
expect(timeline.endCursor?.seq).toBe(500);
expect(timeline.hasOlder).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
+48 -111
View File
@@ -99,6 +99,7 @@ import type {
AgentManagerEvent,
AgentTimelineCursor,
AgentTimelineFetchDirection,
AgentTimelineFetchResult,
ManagedAgent,
} from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
@@ -120,8 +121,7 @@ import {
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
selectProjectedTimelinePage,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
@@ -7415,84 +7415,33 @@ export class Session {
});
}
private loadProjectedTimelineWindow(params: {
agentId: string;
direction: AgentTimelineFetchDirection;
cursor: AgentTimelineCursor | undefined;
requestedLimit: number;
timeline: ReturnType<AgentManager["fetchTimeline"]>;
}): {
timeline: ReturnType<AgentManager["fetchTimeline"]>;
selectedRows: ReturnType<typeof selectTimelineWindowByProjectedLimit>["selectedRows"];
minSeq: number | null;
maxSeq: number | null;
} {
const { agentId, direction, cursor, requestedLimit } = params;
let timeline = params.timeline;
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries = projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
private shouldUseFullTimelineForProjectedPage(input: {
timeline: AgentTimelineFetchResult;
}): boolean {
const { timeline } = input;
if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) {
return false;
}
return {
timeline,
selectedRows: projectedWindow.selectedRows,
minSeq: projectedWindow.minSeq,
maxSeq: projectedWindow.maxSeq,
};
const firstRow = timeline.rows[0];
if (
firstRow?.item.type === "assistant_message" ||
firstRow?.item.type === "reasoning" ||
firstRow?.item.type === "tool_call"
) {
return true;
}
return timeline.rows.some((row) => row.item.type === "tool_call");
}
private async handleFetchAgentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const projection: TimelineProjectionMode = "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
@@ -7508,43 +7457,29 @@ export class Session {
});
const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
limit: pageLimit,
});
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedResult = this.loadProjectedTimelineWindow({
agentId: msg.agentId,
direction,
cursor,
requestedLimit,
timeline,
});
timeline = projectedResult.timeline;
entries = projectTimelineRows({ rows: projectedResult.selectedRows, mode: projection });
if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq };
endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq };
hasOlder = projectedResult.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows({ rows: timeline.rows, mode: projection });
}
const timeline = this.shouldUseFullTimelineForProjectedPage({
timeline: controlTimeline,
})
? this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 })
: controlTimeline;
const projectedPage = selectProjectedTimelinePage({
rows: timeline.rows,
bounds: timeline.window,
direction: controlTimeline.reset ? "tail" : direction,
...(cursor ? { cursorSeq: cursor.seq } : {}),
limit: pageLimit,
});
const startCursor =
projectedPage.startSeq !== null
? { epoch: timeline.epoch, seq: projectedPage.startSeq }
: null;
const endCursor =
projectedPage.endSeq !== null ? { epoch: timeline.epoch, seq: projectedPage.endSeq } : null;
this.emit({
type: "fetch_agent_timeline_response",
@@ -7555,15 +7490,17 @@ export class Session {
direction,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
reset: controlTimeline.reset,
staleCursor: controlTimeline.staleCursor,
gap: controlTimeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder,
hasNewer,
entries: entries.map((entry) => ({
hasOlder:
projectedPage.hasOlder ||
(projectedPage.startSeq !== null && projectedPage.startSeq > timeline.window.minSeq),
hasNewer: projectedPage.hasNewer,
entries: projectedPage.entries.map((entry) => ({
provider: snapshot.provider,
item: entry.item,
timestamp: entry.timestamp,
@@ -96,6 +96,19 @@ it("creates separate terminals for different cwds", async () => {
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
});
it("lists subdirectory terminals when querying the workspace root", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-subdir-root-"));
const subdirCwd = join(rootCwd, "apps", "mobile");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const created = await manager.createTerminal({ cwd: subdirCwd, name: "Mobile" });
const rootTerminals = await manager.getTerminals(rootCwd);
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
});
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
@@ -6,6 +6,7 @@ import {
} from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path";
import { isSameOrDescendantPath } from "../server/path-utils.js";
export interface TerminalListItem {
id: string;
@@ -169,7 +170,16 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd);
return terminalsByCwd.get(cwd) ?? [];
// Terminals are bucketed by exact cwd, but an agent can open a terminal in
// a subdirectory of the workspace. A query for the workspace root must
// surface those too, so aggregate every bucket at or below `cwd`.
const sessions: TerminalSession[] = [];
for (const [bucketCwd, bucketSessions] of terminalsByCwd) {
if (isSameOrDescendantPath(cwd, bucketCwd)) {
sessions.push(...bucketSessions);
}
}
return sessions;
},
async createTerminal(options: {
@@ -10,7 +10,8 @@ import {
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager } from "./terminal-manager.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { isSameOrDescendantPath } from "../server/path-utils.js";
function deferred<T>(): {
promise: Promise<T>;
@@ -140,3 +141,97 @@ describe("terminal-session-controller restore", () => {
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
});
});
function listSession(input: { id: string; name: string; cwd: string }): TerminalSession {
return {
id: input.id,
name: input.name,
cwd: input.cwd,
send: vi.fn(),
subscribe: () => vi.fn(),
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState(""),
getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
}
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("terminal-session-controller subdirectory aggregation", () => {
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
const rootCwd = "/work/repo";
const subdirCwd = "/work/repo/apps/mobile";
// Aggregating subdirectory buckets into the root query is the manager's
// contract, covered by terminal-manager.test.ts. Here we only assert the
// controller re-fetches by root and keys the snapshot by root, so the fake
// returns a fixed aggregated list for the root and nothing otherwise.
const aggregatedRootTerminals = [
listSession({ id: "root-term", name: "Terminal 1", cwd: rootCwd }),
listSession({ id: "subdir-term", name: "Mobile", cwd: subdirCwd }),
];
let changedListener: ((event: TerminalsChangedEvent) => void) | null = null;
const terminalManager: TerminalManager = {
getTerminals: vi.fn(async (cwd: string) => (cwd === rootCwd ? aggregatedRootTerminals : [])),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => [rootCwd, subdirCwd]),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn((listener) => {
changedListener = listener;
return vi.fn();
}),
};
const outboundMessages: SessionOutboundMessage[] = [];
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: vi.fn(),
hasBinaryChannel: () => true,
isPathWithinRoot: isSameOrDescendantPath,
sessionLogger: createLogger(),
});
controller.start();
controller.dispatch({ type: "subscribe_terminals_request", cwd: rootCwd });
await flushMicrotasks();
outboundMessages.length = 0;
changedListener?.({
cwd: subdirCwd,
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }],
});
await flushMicrotasks();
expect(outboundMessages).toEqual([
{
type: "terminals_changed",
payload: {
cwd: rootCwd,
terminals: [
{ id: "root-term", name: "Terminal 1" },
{ id: "subdir-term", name: "Mobile" },
],
},
},
]);
});
});
@@ -125,9 +125,9 @@ export class TerminalSessionController {
if (!this.terminalManager) {
return;
}
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
this.handleTerminalsChanged(event),
);
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
void this.handleTerminalsChanged(event);
});
}
getMetrics(): TerminalSessionControllerMetrics {
@@ -293,31 +293,30 @@ export class TerminalSessionController {
};
}
private handleTerminalsChanged(event: TerminalsChangedEvent): void {
if (!this.subscribedDirectories.has(event.cwd)) {
return;
private async handleTerminalsChanged(event: TerminalsChangedEvent): Promise<void> {
// A terminal can live in a subdirectory of a subscribed workspace root (an
// agent can open one there). Deliver the change to every subscribed root at
// or above the terminal's cwd, keyed by that root, carrying the full
// aggregated list — so the client's cache replacement doesn't drop the
// terminals that live directly at the root.
const matchingRoots = Array.from(this.subscribedDirectories).filter((root) =>
this.isPathWithinRoot(root, event.cwd),
);
for (const root of matchingRoots) {
await this.emitTerminalsSnapshotForRoot(root);
}
this.emitTerminalsChangedSnapshot({
cwd: event.cwd,
terminals: event.terminals.map((terminal) =>
Object.assign(
{ id: terminal.id, name: terminal.name },
terminal.title ? { title: terminal.title } : {},
),
),
});
}
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
this.subscribedDirectories.add(msg.cwd);
void this.emitInitialTerminalsChangedSnapshot(msg.cwd);
void this.emitTerminalsSnapshotForRoot(msg.cwd);
}
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
this.subscribedDirectories.delete(msg.cwd);
}
private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise<void> {
private async emitTerminalsSnapshotForRoot(cwd: string): Promise<void> {
if (!this.terminalManager || !this.subscribedDirectories.has(cwd)) {
return;
}
@@ -1,6 +1,6 @@
import { afterEach, expect, it } from "vitest";
import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
@@ -332,6 +332,24 @@ it("starts the default shell through the worker and accepts quoted commands", as
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
});
it("lists subdirectory terminals when querying the workspace root", async () => {
const rootCwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-subdir-root-"));
const subdirCwd = join(rootCwd, "apps", "mobile");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
manager = createWorkerTerminalManager();
const created = trackTerminal(
await manager.createTerminal({
cwd: subdirCwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
);
const rootTerminals = await manager.getTerminals(rootCwd);
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
});
it("removes worker terminals after killAndWait", async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-"));
temporaryDirs.push(cwd);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"type": "module",
"scripts": {
@@ -31,12 +31,6 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
>
Changelog
</a>
<a
href="/cloud"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Cloud
</a>
<a
href="/docs/cli"
className="block text-muted-foreground hover:text-foreground transition-colors"
@@ -28,12 +28,6 @@ export function SiteHeader() {
>
Changelog
</a>
<a
href="/cloud"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cloud
</a>
<a
href="/download"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"