mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb3157e75 | ||
|
|
de162b98d4 | ||
|
|
0a960f9ae0 | ||
|
|
7c4f8580e9 | ||
|
|
33674e0b10 | ||
|
|
0ecc51bd99 | ||
|
|
526e6070f8 | ||
|
|
40d6a9127a | ||
|
|
3d96ea6354 | ||
|
|
0e3eb9cf75 | ||
|
|
c8ca503cc3 | ||
|
|
41cb1af036 | ||
|
|
17aa957d3e | ||
|
|
2c756e9a8a | ||
|
|
bb6a4db6e0 | ||
|
|
2a13a082b7 | ||
|
|
eea5932a21 | ||
|
|
e04bb942e2 | ||
|
|
3c3574d670 | ||
|
|
44863ec1dd | ||
|
|
47414abc5e | ||
|
|
9860dd36ef |
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
@@ -1 +1 @@
|
||||
sha256-PYbY3Lk9+y2byl1mN9dVO4YGXLEZrmnuPYxDw59LE5g=
|
||||
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
|
||||
|
||||
Generated
+629
-47
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.86",
|
||||
"version": "0.1.87",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user