mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
51
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 | ||
|
|
9e0c9d0662 | ||
|
|
654ebadaf5 | ||
|
|
c2b4f3aa18 | ||
|
|
64e3a7152d | ||
|
|
daae71d1a8 | ||
|
|
c1453b25a7 | ||
|
|
c47f3190d8 | ||
|
|
0803d5ff41 | ||
|
|
7ed0ce97df | ||
|
|
fcd93a23ac | ||
|
|
ef3b8a42fd | ||
|
|
8b1bb90ce3 | ||
|
|
524aef5d75 | ||
|
|
69d36c76fe | ||
|
|
38f0560f9b | ||
|
|
25d6fc8515 | ||
|
|
d1eb976653 | ||
|
|
14d176a4e2 | ||
|
|
04fb0f9b82 | ||
|
|
cdcf3d8d55 | ||
|
|
cfc9665f6a | ||
|
|
1350167dda | ||
|
|
bb2f540f29 | ||
|
|
f861aa2e88 | ||
|
|
3585c80266 | ||
|
|
0ee76b2ea0 | ||
|
|
65ace20880 | ||
|
|
2e1b907c63 | ||
|
|
23502e474d |
@@ -1,5 +1,41 @@
|
||||
# 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
|
||||
|
||||
- **Launch Grok (xAI) as a coding agent**
|
||||
- **Fast mode for Claude Opus**
|
||||
- **Multilingual local dictation with the new Parakeet v3 speech model**
|
||||
|
||||
### Improved
|
||||
|
||||
- Edit, Write, and Read tool calls are now syntax-highlighted
|
||||
- The model selector shows the error when a provider fails to load
|
||||
- The About page shows the versions of connected host daemons
|
||||
- Refresh git diffs on demand with a new refresh button
|
||||
- Previews can open readable files outside the current workspace
|
||||
- Projects without an icon now show a colored icon instead of a grey placeholder
|
||||
- Auto-generated agent titles and worktree branch names now use your configured provider fallbacks ([#1219](https://github.com/getpaseo/paseo/pull/1219) by [@mcowger](https://github.com/mcowger))
|
||||
- Local dictation keeps its speech models out of the daemon, lowering its memory use
|
||||
|
||||
### Fixed
|
||||
|
||||
- On mobile, the whole composer now stays above the keyboard so the subagents track and draft pills no longer hide behind it
|
||||
- The mobile agent timeline now catches up fully after reconnecting, so no messages go missing
|
||||
- The slash command menu no longer shows /clear twice
|
||||
|
||||
## 0.1.85 - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,6 +46,8 @@ By default, the daemon binds to `127.0.0.1`. With no password configured, the lo
|
||||
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
@@ -92,6 +92,7 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
@@ -222,7 +223,7 @@ initializing → idle ⇄ running
|
||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
|
||||
## Agent providers
|
||||
|
||||
+6
-1
@@ -155,7 +155,10 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
||||
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
||||
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
providers: Record<providerId, ProviderOverride>,
|
||||
metadataGeneration: {
|
||||
providers: [{ provider, model?, thinkingOptionId? }]
|
||||
}
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
@@ -171,6 +174,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,44 @@
|
||||
# Timeline sync
|
||||
|
||||
Agent chat delivery has two paths:
|
||||
|
||||
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:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
- device type
|
||||
- app visibility
|
||||
- focused agent
|
||||
- last activity time
|
||||
|
||||
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
|
||||
|
||||
## Catch-up is paged but complete
|
||||
|
||||
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.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
||||
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-25V9uu0hSecGCDixaJwQ9wu6Mj0cfG6uXNYjMZIZ58U=
|
||||
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
|
||||
|
||||
Generated
+629
-47
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.85",
|
||||
"version": "0.1.87",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.85",
|
||||
"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.85",
|
||||
"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.85",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.85",
|
||||
"@getpaseo/protocol": "0.1.85",
|
||||
"@getpaseo/server": "0.1.85",
|
||||
"@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.85",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.85",
|
||||
"@getpaseo/relay": "0.1.85",
|
||||
"@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.85",
|
||||
"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.85",
|
||||
"version": "0.1.87",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37090,7 +37672,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.85",
|
||||
"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.85",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37340,7 +37922,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.85",
|
||||
"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.85",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.85",
|
||||
"@getpaseo/highlight": "0.1.85",
|
||||
"@getpaseo/protocol": "0.1.85",
|
||||
"@getpaseo/relay": "0.1.85",
|
||||
"@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.85",
|
||||
"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.85",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
||||
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
expectSessionsEmptyState,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Sessions screen empty state", () => {
|
||||
test.beforeAll(async () => {
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
||||
if (history.entries.length > 0) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
@@ -12,19 +14,13 @@ const ACP_PROVIDER = {
|
||||
name: "Hermes",
|
||||
};
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
test.describe("ACP provider catalog", () => {
|
||||
test("adds a catalog provider from settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getSeededServerId());
|
||||
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);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "./fixtures";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
@@ -21,14 +21,14 @@ import {
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectArchiveTabDaemonClient();
|
||||
client = await connectSeedClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
expectSessionRowArchived,
|
||||
expectWorkspaceTabHidden,
|
||||
@@ -11,22 +9,12 @@ import {
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
interface SlashCommandScenario {
|
||||
agent: { id: string };
|
||||
client: TerminalPerfDaemonClient;
|
||||
cwd: string;
|
||||
agentId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function withOpenReadyMockAgent(
|
||||
page: Page,
|
||||
input: {
|
||||
@@ -36,68 +24,23 @@ async function withOpenReadyMockAgent(
|
||||
},
|
||||
run: (scenario: SlashCommandScenario) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const repo = await createTempGitRepo("client-slash-command-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await createReadyMockAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modeId: input.modeId,
|
||||
});
|
||||
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await run({ agent, client, cwd: repo.path, title: input.title });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReadyMockAgent(
|
||||
client: TerminalPerfDaemonClient,
|
||||
input: {
|
||||
cwd: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "client-slash-command-",
|
||||
title: input.title,
|
||||
modeId: input.modeId ?? "load-test",
|
||||
model: input.model ?? "ten-second-stream",
|
||||
model: input.model,
|
||||
modeId: input.modeId,
|
||||
initialPrompt: "Prepare a client slash command test agent.",
|
||||
});
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
async function openActiveAgentTab(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceTabVisible(page, input.agentId);
|
||||
await expectComposerVisible(page);
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await run({ agentId: session.agentId, title: input.title });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
||||
@@ -169,9 +112,9 @@ async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promis
|
||||
|
||||
test.describe("Client slash commands", () => {
|
||||
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
|
||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => {
|
||||
await runClientSlashCommand(page, "/quit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
});
|
||||
});
|
||||
@@ -180,9 +123,9 @@ test.describe("Client slash commands", () => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash quit autocomplete e2e" },
|
||||
async ({ agent, title }) => {
|
||||
async ({ agentId, title }) => {
|
||||
await selectClientSlashCommand(page, "/qu", "/exit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
@@ -192,12 +135,12 @@ test.describe("Client slash commands", () => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
||||
async ({ agent, title }) => {
|
||||
async ({ agentId, title }) => {
|
||||
await runClientSlashCommand(page, "/clear");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
await expectReplacementDraftMatchesPreviousSetup(page);
|
||||
await createAgentFromReplacementDraft(page);
|
||||
await waitForReplacementAgentId(page, agent.id);
|
||||
await waitForReplacementAgentId(page, agentId);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
test.describe("Codex plan approval", () => {
|
||||
test("shows a single actionable plan panel and removes it after implementation starts", async ({
|
||||
@@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => {
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const repo = await createTempGitRepo("codex-plan-approval-");
|
||||
const client = await connectTerminalClient();
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "codex-plan-approval-",
|
||||
title: "Codex plan approval e2e",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
try {
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Codex plan approval e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
repo.path,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
@@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => {
|
||||
});
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,16 +23,12 @@ import {
|
||||
expectGithubAttachmentPill,
|
||||
openGithubWorkspace,
|
||||
} from "./helpers/composer";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
@@ -237,27 +233,23 @@ test.describe("Composer attachments", () => {
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
const serverId = getServerId();
|
||||
|
||||
const repo = await createTempGitRepo("attach-lock-");
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const daemonClient = await connectNewWorkspaceDaemonClient();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
targetWorkspacePath: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
const createButton = page
|
||||
@@ -277,8 +269,7 @@ test.describe("Composer attachments", () => {
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await daemonClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
@@ -99,28 +98,8 @@ async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
);
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort || daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
@@ -163,58 +142,26 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
|
||||
const operation = cleanup().catch(() => undefined);
|
||||
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
): Promise<{
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const repo = await createTempGitRepo("autocomplete-popover-");
|
||||
const client = await connectTerminalClient();
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-popover-",
|
||||
title: "Autocomplete popover regression",
|
||||
});
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Autocomplete popover regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const route = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
repo.path,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(route);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await openAgentRoute(page, session);
|
||||
if (options?.expectWorkspaceTab !== false) {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
}
|
||||
await expectComposerVisible(page);
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await cleanupWithin(2_000, () => client.close());
|
||||
await cleanupWithin(2_000, () => repo.cleanup());
|
||||
},
|
||||
};
|
||||
return { cleanup: session.cleanup };
|
||||
} catch (error) {
|
||||
await cleanupWithin(2_000, () => client.close());
|
||||
await cleanupWithin(2_000, () => repo.cleanup());
|
||||
await session.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
@@ -17,20 +18,12 @@ import {
|
||||
expectDaemonStatusVersion,
|
||||
} from "./helpers/desktop-updates";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||
test.describe("Desktop updates", () => {
|
||||
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getSeededServerId(),
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
});
|
||||
@@ -41,7 +34,7 @@ test.describe("Desktop updates", () => {
|
||||
|
||||
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getSeededServerId(),
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
slowInstall: true,
|
||||
@@ -58,7 +51,7 @@ test.describe("Desktop daemon management", () => {
|
||||
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
@@ -74,7 +67,7 @@ test.describe("Desktop daemon management", () => {
|
||||
});
|
||||
|
||||
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
@@ -89,7 +82,7 @@ test.describe("Desktop daemon management", () => {
|
||||
});
|
||||
|
||||
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
@@ -106,7 +99,7 @@ test.describe("Desktop daemon management", () => {
|
||||
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
@@ -124,7 +117,7 @@ test.describe("Desktop daemon management", () => {
|
||||
});
|
||||
|
||||
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
|
||||
@@ -9,41 +9,31 @@ import {
|
||||
openFileFromExplorer,
|
||||
} from "./helpers/file-explorer";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
|
||||
files: [
|
||||
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||
],
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-explorer-collapse-",
|
||||
repo: {
|
||||
files: [
|
||||
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||
],
|
||||
},
|
||||
});
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await seedClient?.close();
|
||||
await tempRepo?.cleanup();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File explorer collapse", () => {
|
||||
test("collapses an opened image file parent folder and still expands other folders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expandFolder(page, "assets");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
@@ -17,20 +18,8 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
||||
},
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const daemonPort = getE2EDaemonPort();
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
|
||||
@@ -13,147 +9,6 @@ export interface ScrollMetrics {
|
||||
distanceFromBottom: number;
|
||||
}
|
||||
|
||||
export interface SeededAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
export interface DaemonClientInstance {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
return Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = (index + 1).toString().padStart(2, "0");
|
||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
message: string;
|
||||
expectedReply: string;
|
||||
} {
|
||||
return {
|
||||
message: buildReplyMessage(label),
|
||||
expectedReply: buildReplyBlock(label),
|
||||
};
|
||||
}
|
||||
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
DaemonClientConfig,
|
||||
DaemonClientInstance
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedBottomAnchorAgent(input: {
|
||||
client: DaemonClientInstance;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
@@ -190,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
||||
if (expectedTailText) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNearBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -241,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
@@ -262,11 +66,3 @@ export async function waitForContentGrowth(
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
@@ -16,90 +16,40 @@ export interface ArchiveTabAgent {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
daemon: buildSeededHost({
|
||||
serverId: getServerId(),
|
||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
};
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
ArchiveTabDaemonClientConfig,
|
||||
ArchiveTabDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
/**
|
||||
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
|
||||
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
|
||||
* idle agent from the same client it uses for everything else.
|
||||
*/
|
||||
export interface IdleAgentSeedClient {
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
}): Promise<{ id: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: ArchiveTabDaemonClient,
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
@@ -125,7 +75,7 @@ export async function createIdleAgent(
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: ArchiveTabDaemonClient,
|
||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
await client.archiveAgent(agentId);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
@@ -145,7 +146,7 @@ export async function selectGithubOption(
|
||||
}
|
||||
|
||||
export interface MockAgentSetup {
|
||||
client: TerminalPerfDaemonClient;
|
||||
client: SeedDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
}
|
||||
|
||||
@@ -154,11 +155,10 @@ export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
const serverId = getServerId();
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectTerminalClient();
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const agent = await client.createAgent({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
|
||||
new (config: ClientConfig) => ClientInstance
|
||||
@@ -13,3 +16,40 @@ export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
interface E2EDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects an in-test daemon client over the isolated E2E daemon's WebSocket.
|
||||
* The port-6767 guard keeps tests off the developer daemon. Each helper passes
|
||||
* its own typed client interface as the generic.
|
||||
*/
|
||||
export async function connectDaemonClient<ClientInstance extends { connect(): Promise<void> }>(
|
||||
options: ConnectDaemonClientOptions,
|
||||
): Promise<ClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||
const client = new DaemonClient({
|
||||
url: resolveDaemonWsUrl(),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion,
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
/**
|
||||
* Resolves the isolated E2E daemon's port, which Playwright's globalSetup
|
||||
* publishes into the environment before any spec runs. Helpers and specs that
|
||||
* build daemon WebSocket URLs, route patterns, or host endpoints share this
|
||||
* accessor instead of re-reading the env var.
|
||||
*
|
||||
* The port-6767 guard is a hard guardrail: 6767 is the developer's default
|
||||
* daemon, which manages real agents. The e2e port is never legitimately 6767,
|
||||
* so refusing it here keeps every test off the developer daemon.
|
||||
*/
|
||||
export function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon (6767).");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the
|
||||
* `:<port>` segment at a word boundary, so it catches the URL regardless of
|
||||
* host or path. Use this when intercepting connections to an arbitrary port
|
||||
* (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer
|
||||
* `daemonWsRoutePattern()`.
|
||||
*/
|
||||
export function wsRoutePatternForPort(port: string): RegExp {
|
||||
return new RegExp(`:${escapeRegex(port)}\\b`);
|
||||
}
|
||||
|
||||
/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */
|
||||
export function daemonWsRoutePattern(): RegExp {
|
||||
return wsRoutePatternForPort(getE2EDaemonPort());
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { openSettingsHost } from "./settings";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -26,9 +27,8 @@ export interface RealDaemonState {
|
||||
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||
*/
|
||||
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
client: SeedDaemonClient;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a temp git repo, opens it as a project, and creates a ready mock-provider
|
||||
* agent in it via the daemon. Returns the agent id plus a cleanup that closes the
|
||||
* client and removes the repo. Pair with {@link openAgentRoute} to drive the UI.
|
||||
*/
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): Promise<MockAgentWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
initialPrompt: options.initialPrompt,
|
||||
featureValues: options.featureValues,
|
||||
});
|
||||
return {
|
||||
agentId: agent.id,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
export async function openAgentRoute(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await page.goto(buildAgentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
@@ -16,13 +15,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "openProject"
|
||||
>;
|
||||
|
||||
interface NewWorkspaceDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
|
||||
export interface OpenedProject {
|
||||
@@ -32,21 +24,6 @@ export interface OpenedProject {
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: OpenProjectPayload) {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -69,19 +46,9 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
NewWorkspaceDaemonClientConfig,
|
||||
NewWorkspaceDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||
clientIdPrefix: "app-e2e-new-workspace",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function openProjectViaDaemon(
|
||||
@@ -316,12 +283,7 @@ export interface AgentCreatedDelayControl {
|
||||
export async function delayBrowserAgentCreatedStatus(
|
||||
page: Page,
|
||||
): Promise<AgentCreatedDelayControl> {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
@@ -170,19 +171,13 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
function buildDaemonPortPattern(): RegExp {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
}
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
@@ -230,7 +225,7 @@ export async function installDaemonConnectionGate(
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Escape a literal string so it can be embedded safely inside a `RegExp`.
|
||||
* Used across the suite to build dynamic patterns from daemon ports, URLs,
|
||||
* workspace routes, and user-visible text without regex injection.
|
||||
*/
|
||||
export function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
page: Page;
|
||||
client: TerminalPerfDaemonClient;
|
||||
client: SeedDaemonClient;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
@@ -33,14 +34,6 @@ interface ProviderLaunchConfig {
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
const REWIND_TIMEOUT_MS = 120_000;
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
@@ -173,7 +166,7 @@ export async function launchAgent(input: {
|
||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectTerminalClient();
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
@@ -324,15 +317,15 @@ export async function cleanupRewindFlow(input: {
|
||||
}
|
||||
|
||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||
const client = handle.client as TerminalPerfDaemonClient & {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import path from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
* browser. Domain-specific helpers wrap it for their own flows; specs should
|
||||
* prefer those wrappers over reaching for this client directly.
|
||||
*/
|
||||
export interface SeedDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
): void;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(): Promise<SeedDaemonClient> {
|
||||
return connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "seed",
|
||||
appVersion: loadAppVersion(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A temp project opened as a workspace, with a seed client connected to drive
|
||||
* it out of band. `cleanup` closes the client and removes the project. This is
|
||||
* the canonical bootstrap for specs that need a real workspace plus daemon
|
||||
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
|
||||
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
|
||||
*/
|
||||
export interface SeededWorkspace {
|
||||
client: SeedDaemonClient;
|
||||
repoPath: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
/** Stable project identity the daemon groups workspaces under. */
|
||||
projectId: string;
|
||||
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||
projectDisplayName: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function seedWorkspace(options: {
|
||||
repoPrefix: string;
|
||||
/** Repo fixture options; only applies to git projects (the default). */
|
||||
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
||||
git?: boolean;
|
||||
}): Promise<SeededWorkspace> {
|
||||
const project =
|
||||
options.git === false
|
||||
? await createTempDirectory(options.repoPrefix)
|
||||
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(project.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolves the isolated E2E daemon's server id, which Playwright's globalSetup
|
||||
* publishes into the environment before any spec runs. Helpers and specs that
|
||||
* build host routes or `sidebar-workspace-row-${serverId}:${id}` selectors share
|
||||
* this accessor instead of re-reading the env var.
|
||||
*/
|
||||
export function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { requireServerId } from "./sidebar";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
import { TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
@@ -16,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();
|
||||
@@ -31,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> {
|
||||
@@ -40,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();
|
||||
}
|
||||
@@ -71,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> {
|
||||
@@ -108,18 +125,18 @@ 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$`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||
const serverId = requireServerId();
|
||||
const serverId = getServerId();
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||
const serverId = requireServerId();
|
||||
const serverId = getServerId();
|
||||
await openSettingsHost(page, serverId);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
@@ -159,7 +176,7 @@ export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectAboutContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("App version", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
@@ -184,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();
|
||||
@@ -196,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({
|
||||
@@ -247,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();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
@@ -78,7 +79,7 @@ class StartupScenario {
|
||||
}
|
||||
|
||||
for (const port of this.blockedEndpointPorts) {
|
||||
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
||||
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
||||
});
|
||||
}
|
||||
@@ -227,7 +228,3 @@ function buildStoredHost(input: {
|
||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||
return buildCreateAgentPreferences(serverId);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
navigateToTerminal,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./terminal-perf";
|
||||
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
|
||||
interface TempRepo {
|
||||
path: string;
|
||||
@@ -19,12 +15,12 @@ export interface TerminalInstance {
|
||||
}
|
||||
|
||||
export class TerminalE2EHarness {
|
||||
readonly client: TerminalPerfDaemonClient;
|
||||
readonly client: SeedDaemonClient;
|
||||
readonly tempRepo: TempRepo;
|
||||
readonly workspaceId: string;
|
||||
|
||||
private constructor(input: {
|
||||
client: TerminalPerfDaemonClient;
|
||||
client: SeedDaemonClient;
|
||||
tempRepo: TempRepo;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
@@ -35,7 +31,7 @@ export class TerminalE2EHarness {
|
||||
|
||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||
const client = await connectTerminalClient();
|
||||
const client = await connectSeedClient();
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
if (!seedResult.workspace) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -1,111 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface TerminalPerfDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: { id: string; name: string; projectRootPath: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
): void;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
TerminalPerfDaemonClientConfig,
|
||||
TerminalPerfDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: loadAppVersion(),
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { createTempGitRepo, resolveTempRoot } from "./workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
@@ -49,7 +48,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
|
||||
let workspacePath = repo.path;
|
||||
if (options?.worktree) {
|
||||
const tempRoot = await realpath("/tmp");
|
||||
const tempRoot = await resolveTempRoot();
|
||||
workspacePath = path.join(
|
||||
tempRoot,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
|
||||
@@ -1,58 +1,25 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
fetchWorkspaces(): Promise<{
|
||||
entries: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
}>;
|
||||
}>;
|
||||
fetchAgents(): Promise<{
|
||||
entries: Array<{
|
||||
agent: { id: string; cwd: string; workspaceId?: string | null };
|
||||
}>;
|
||||
}>;
|
||||
fetchAgent(agentId: string): Promise<{
|
||||
agent: { id: string; cwd: string } | null;
|
||||
project: unknown;
|
||||
} | null>;
|
||||
listTerminals(cwd: string): Promise<{
|
||||
cwd?: string;
|
||||
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||
error?: string | null;
|
||||
}>;
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||
}
|
||||
type WorkspaceSetupDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "close"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "fetchAgent"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "listTerminals"
|
||||
| "openProject"
|
||||
| "subscribeRawMessages"
|
||||
>;
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -61,43 +28,30 @@ export type WorkspaceSetupProgressPayload = Extract<
|
||||
|
||||
export type { WorkspaceSetupDaemonClient };
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
return connectDaemonClient<WorkspaceSetupDaemonClient>({ clientIdPrefix: "workspace-setup" });
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
{
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
},
|
||||
WorkspaceSetupDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `workspace-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
export async function openProjectViaDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||
const result = await client.openProject(repoPath);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
workspaceDirectory: result.workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
const result = await client.openProject(repoPath);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
}
|
||||
await openProjectViaDaemon(client, repoPath);
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
@@ -304,11 +258,11 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
targetWorkspacePath: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoHome } from "./app";
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
@@ -23,10 +24,6 @@ export function workspaceLabelFromPath(value: string): string {
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, "");
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
|
||||
@@ -9,6 +9,20 @@ interface TempRepo {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TempDirectory {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp →
|
||||
* /private/tmp) so fixture paths match the daemon's resolved paths; on Windows
|
||||
* `/tmp` doesn't exist, so fall back to the OS temp dir.
|
||||
*/
|
||||
export async function resolveTempRoot(): Promise<string> {
|
||||
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
}
|
||||
|
||||
async function configureRemote(input: {
|
||||
repoPath: string;
|
||||
withRemote: boolean;
|
||||
@@ -22,6 +36,15 @@ async function configureRemote(input: {
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
if (originUrl) {
|
||||
// Relabel origin to a display URL after the local tracking remote is set
|
||||
// up, so project grouping shows the remote's owner/repo while branch
|
||||
// tracking refs still resolve locally (no fetch from the synthetic URL).
|
||||
execSync(`git remote set-url origin ${JSON.stringify(originUrl)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (originUrl) {
|
||||
@@ -44,9 +67,7 @@ export const createTempGitRepo = async (
|
||||
},
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const repoPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||
@@ -110,6 +131,21 @@ export const createTempGitRepo = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A plain (non-git) directory opened as a project. The daemon shows its
|
||||
* basename as the project name, since there's no remote to group under.
|
||||
*/
|
||||
export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<TempDirectory> {
|
||||
const dirPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
|
||||
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: async () => {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||
currentBranch: string;
|
||||
hasAncestor: (ref: string) => boolean;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
@@ -16,30 +15,19 @@ import {
|
||||
terminalSurfaceLocator,
|
||||
} from "./helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./helpers/terminal-perf";
|
||||
import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
|
||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: TerminalPerfDaemonClient;
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||
seedClient = await connectTerminalClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||
workspaceId = result.workspace.id;
|
||||
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (seedClient) await seedClient.close();
|
||||
if (tempRepo) await tempRepo.cleanup();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -48,7 +36,7 @@ test.afterAll(async () => {
|
||||
|
||||
test.describe("Tab creation", () => {
|
||||
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
@@ -56,7 +44,7 @@ test.describe("Tab creation", () => {
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
@@ -75,7 +63,7 @@ test.describe("Tab creation", () => {
|
||||
});
|
||||
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
|
||||
@@ -88,7 +76,7 @@ test.describe("Tab creation", () => {
|
||||
|
||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
|
||||
@@ -100,7 +88,7 @@ test.describe("Tab creation", () => {
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertTerminalTileVisible(page);
|
||||
@@ -117,26 +105,16 @@ test.describe("Terminal title propagation", () => {
|
||||
// must re-render before the assertion deadline. Allow retries.
|
||||
test.describe.configure({ retries: 2 });
|
||||
|
||||
let client: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
client = await connectTerminalClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) await client.close();
|
||||
});
|
||||
|
||||
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
@@ -153,19 +131,19 @@ test.describe("Terminal title propagation", () => {
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
@@ -188,7 +166,7 @@ test.describe("Terminal title propagation", () => {
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -199,7 +177,7 @@ test.describe("Terminal title propagation", () => {
|
||||
|
||||
test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
||||
@@ -221,7 +199,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
@@ -237,7 +215,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
});
|
||||
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
selectPickerOptionByKeyboard,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
@@ -61,10 +62,7 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
||||
@@ -118,10 +116,7 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const repo = await createTempGitRepo("workspace-nav-same-project-");
|
||||
|
||||
@@ -201,10 +196,7 @@ test.describe("New workspace flow", () => {
|
||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-");
|
||||
|
||||
@@ -276,10 +268,7 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
@@ -355,10 +344,7 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
||||
branches: ["main", "dev"],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openProjectViaDaemon,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
@@ -50,11 +51,8 @@ test.describe("PR pane", () => {
|
||||
});
|
||||
|
||||
for (const pr of repoFixture.prs) {
|
||||
const result = await seedClient.openProject(pr.localPath);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
|
||||
}
|
||||
workspaceByTitle.set(pr.title, result.workspace.id);
|
||||
const workspace = await openProjectViaDaemon(seedClient, pr.localPath);
|
||||
workspaceByTitle.set(pr.title, workspace.id);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
@@ -63,38 +62,36 @@ const initialPaseoConfig = {
|
||||
|
||||
const test = base.extend<ProjectsSettingsFixtures>({
|
||||
editableProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "projects-settings-",
|
||||
repo: { paseoConfig: initialPaseoConfig },
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
name: workspace.projectDisplayName,
|
||||
path: workspace.repoPath,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
// Defensive: restore directory write permission in case the test left it blocked
|
||||
// (write_failed test), so that repo.cleanup() can remove files inside.
|
||||
await chmod(repo.path, 0o755).catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
// (write_failed test), so that cleanup can remove files inside.
|
||||
await chmod(workspace.repoPath, 0o755).catch(() => undefined);
|
||||
await workspace.cleanup();
|
||||
},
|
||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-gitlab-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
originUrl: "https://gitlab.com/acme/app.git",
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "projects-settings-gitlab-",
|
||||
repo: {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
originUrl: "https://gitlab.com/acme/app.git",
|
||||
},
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
name: workspace.projectDisplayName,
|
||||
path: workspace.repoPath,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
@@ -12,50 +10,24 @@ import {
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "rewind-e2e-",
|
||||
title: "Rewind e2e",
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 1);
|
||||
@@ -116,31 +88,25 @@ test.describe("Rewind sheet", () => {
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-failure-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
||||
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "rewind-failure-e2e-",
|
||||
title: "Rewind failure e2e",
|
||||
initialPrompt: firstPrompt,
|
||||
featureValues: {
|
||||
mockRewindError: rewindError,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind failure e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
featureValues: {
|
||||
mockRewindError: rewindError,
|
||||
},
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
|
||||
@@ -160,8 +126,7 @@ test.describe("Rewind sheet", () => {
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,103 +1,125 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openSettingsHost,
|
||||
openHostSection,
|
||||
expectHostLabelDisplayed,
|
||||
clickEditHostLabel,
|
||||
expectHostLabelEditMode,
|
||||
expectHostConnectionsCard,
|
||||
expectHostInjectMcpCard,
|
||||
expectHostActionCards,
|
||||
expectHostProvidersCard,
|
||||
expectHostNoLocalOnlyRows,
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
} from "./helpers/settings";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getSeededDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const port = getSeededDaemonPort();
|
||||
test("connections section shows the seeded connection endpoint", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const port = getE2EDaemonPort();
|
||||
|
||||
await gotoAppShell(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 }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
|
||||
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 = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
|
||||
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 = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(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 }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const serverId = getServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
|
||||
|
||||
@@ -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,21 +1,9 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import { createIdleAgent, openWorkspaceWithAgents } from "./helpers/archive-tab";
|
||||
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
@@ -61,20 +49,17 @@ test.describe("Settings toggle tab regression", () => {
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("settings-toggle-tab-");
|
||||
const agentIds: string[] = [];
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `settings-toggle-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `settings-toggle-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
@@ -89,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await expectSendBehavior(page, "interrupt");
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
@@ -97,11 +82,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,31 +90,28 @@ test.describe("Settings toggle tab regression", () => {
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("agent-route-refresh-");
|
||||
const agentIds: string[] = [];
|
||||
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `agent-route-refresh-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `agent-route-refresh-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: repo.path,
|
||||
workspaceId: workspace.repoPath,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: repo.path,
|
||||
workspaceId: workspace.repoPath,
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
@@ -143,11 +121,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
}
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
@@ -21,21 +13,6 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openProjectViaDaemon(
|
||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||
cwd: string,
|
||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||
const result = await client.openProject(cwd);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
name: result.workspace.name,
|
||||
workspaceDirectory: result.workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
@@ -59,12 +36,10 @@ test.describe("Sidebar workspace rename", () => {
|
||||
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-rename-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
expect(workspace.name).toBe("main");
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
const renameRequests = captureWsSessionFrames(
|
||||
page,
|
||||
@@ -76,18 +51,18 @@ test.describe("Sidebar workspace rename", () => {
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const input = await openRenameModal(page, workspace.id);
|
||||
const input = await openRenameModal(page, workspace.workspaceId);
|
||||
await expect(input).toHaveValue("main");
|
||||
await input.fill("Feature Rename 2");
|
||||
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
"feature-rename-2",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
@@ -99,40 +74,42 @@ test.describe("Sidebar workspace rename", () => {
|
||||
});
|
||||
|
||||
const currentBranchOnDisk = execSync("git branch --show-current", {
|
||||
cwd: repo.path,
|
||||
cwd: workspace.repoPath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(currentBranchOnDisk).toBe("feature-rename-2");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-rename-error-",
|
||||
repo: { branches: ["taken"] },
|
||||
});
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
const input = await openRenameModal(page, workspace.id);
|
||||
const input = await openRenameModal(page, workspace.workspaceId);
|
||||
await expect(input).toHaveValue("main");
|
||||
|
||||
await input.fill("taken");
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
|
||||
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
|
||||
const errorNode = page.getByTestId(
|
||||
workspaceRenameModalTestId(workspace.workspaceId, "error"),
|
||||
);
|
||||
await expect(errorNode).toBeVisible({ timeout: 15_000 });
|
||||
await expect(errorNode).toContainText(/already exists|branch/i);
|
||||
await expect(input).toBeVisible();
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
"main",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
@@ -10,59 +7,17 @@ import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
||||
|
||||
function getWorkspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function setGitHubRemote(repoPath: string): void {
|
||||
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: async () => {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openProjectViaDaemon(
|
||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||
cwd: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.openProject(cwd);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function openWorkspaceFromSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
@@ -92,15 +47,15 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
setGitHubRemote(repo.path);
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
@@ -108,81 +63,73 @@ test.describe("Sidebar workspace list", () => {
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(repo.path));
|
||||
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project shows workspace under it", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
|
||||
await waitForSidebarProject(page, path.basename(repo.path));
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-git project shows directory name", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const project = await createTempDirectory("sidebar-directory-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||
|
||||
try {
|
||||
await openProjectViaDaemon(client, project.path);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
|
||||
await expect(projectRow).toContainText(path.basename(project.path));
|
||||
const directoryName = path.basename(workspace.repoPath);
|
||||
const projectRow = await waitForSidebarProject(page, directoryName);
|
||||
await expect(projectRow).toContainText(directoryName);
|
||||
} finally {
|
||||
await client.close();
|
||||
await project.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
setGitHubRemote(repo.path);
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
await openWorkspaceFromSidebar(page, workspace.id);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.name,
|
||||
title: workspace.workspaceName,
|
||||
subtitle: "test-owner/test-repo",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-branch-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(repo.path));
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
expect(workspace.name).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
||||
"main",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { test } from "./fixtures";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { startupScenario } from "./helpers/startup-dsl";
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function getE2EServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
test.describe("Startup loading presentation", () => {
|
||||
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
||||
const startup = await startupScenario(page)
|
||||
@@ -49,7 +35,7 @@ test.describe("Startup loading presentation", () => {
|
||||
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getE2EServerId();
|
||||
const serverId = getServerId();
|
||||
const startup = await startupScenario(page)
|
||||
.withPendingDesktopDaemon()
|
||||
.withBlockedPort(getE2EDaemonPort())
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
navigateToTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
measureKeystrokeLatency,
|
||||
computePercentile,
|
||||
round2,
|
||||
type TerminalPerfDaemonClient,
|
||||
type LatencySample,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
@@ -20,43 +16,26 @@ const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
terminalPerfDescribe("Terminal wire performance", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("perf-");
|
||||
client = await connectTerminalClient();
|
||||
// Seed the workspace in the daemon so the app can resolve the path
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||
workspaceId = seedResult.workspace.id;
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "perf-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) {
|
||||
await client.close();
|
||||
}
|
||||
if (tempRepo) {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "throughput");
|
||||
if (!result.terminal) {
|
||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
}
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
const created = await harness.createTerminal({ name: "throughput" });
|
||||
try {
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
await harness.openTerminal(page, { terminalId: created.id });
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const terminal = harness.terminalSurface(page);
|
||||
const startMs = Date.now();
|
||||
|
||||
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||
@@ -97,25 +76,20 @@ terminalPerfDescribe("Terminal wire performance", () => {
|
||||
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
||||
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
await harness.killTerminal(created.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "latency");
|
||||
if (!result.terminal) {
|
||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
}
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
const created = await harness.createTerminal({ name: "latency" });
|
||||
try {
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
await harness.openTerminal(page, { terminalId: created.id });
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
// Ensure clean prompt state
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const terminal = harness.terminalSurface(page);
|
||||
await terminal.press("Control+c");
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
@@ -166,7 +140,7 @@ terminalPerfDescribe("Terminal wire performance", () => {
|
||||
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
await harness.killTerminal(created.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerEditable,
|
||||
@@ -10,27 +8,6 @@ import {
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||
}
|
||||
@@ -50,8 +27,10 @@ async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
||||
|
||||
test.describe("User message UI contract", () => {
|
||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("user-message-contract-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-contract-e2e-",
|
||||
title: "User message contract e2e",
|
||||
});
|
||||
const prompts = [
|
||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||
@@ -59,15 +38,8 @@ test.describe("User message UI contract", () => {
|
||||
];
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "User message contract e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const prompt = prompts[index]!;
|
||||
@@ -85,8 +57,7 @@ test.describe("User message UI contract", () => {
|
||||
await expectUserMessageCount(page, 3);
|
||||
await expectIdleComposer(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabVisible,
|
||||
} from "./helpers/archive-tab";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
||||
@@ -34,13 +23,12 @@ test.describe("Workspace agent tab rename", () => {
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-agent-rename-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-rename-" });
|
||||
|
||||
try {
|
||||
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
@@ -81,8 +69,7 @@ test.describe("Workspace agent tab rename", () => {
|
||||
expect(lastFrame.name).toBe(renamed);
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface WorkspaceTabProbeRecord {
|
||||
@@ -20,14 +20,6 @@ interface CapturedCreateAgentFrame {
|
||||
configTitle: string | null;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type ProbeRecord = WorkspaceTabProbeRecord;
|
||||
@@ -122,10 +114,7 @@ function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
|
||||
return record.tabs.some(tabHasLoadingTitle);
|
||||
}
|
||||
|
||||
async function waitForCreatedAgentId(
|
||||
client: TerminalPerfDaemonClient,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -146,7 +135,7 @@ async function waitForCreatedAgentId(
|
||||
}
|
||||
|
||||
async function fetchActiveAgentTitle(
|
||||
client: TerminalPerfDaemonClient,
|
||||
client: SeedDaemonClient,
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
@@ -179,15 +168,9 @@ test.describe("Workspace agent title handoff", () => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const client = await connectTerminalClient();
|
||||
const repo = await createTempGitRepo("workspace-title-handoff-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" });
|
||||
|
||||
try {
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? "Failed to open test workspace");
|
||||
}
|
||||
|
||||
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -196,7 +179,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").click();
|
||||
await expectComposerVisible(page);
|
||||
@@ -206,7 +189,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
await installWorkspaceTabProbe(page);
|
||||
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
||||
|
||||
const agentId = await waitForCreatedAgentId(client, repo.path);
|
||||
const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath);
|
||||
await expect
|
||||
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
||||
timeout: 10_000,
|
||||
@@ -219,9 +202,9 @@ test.describe("Workspace agent title handoff", () => {
|
||||
|
||||
await waitForPromptTabAgentActions(page, promptTitle);
|
||||
|
||||
await client.updateAgent(agentId, { name: generatedTitle });
|
||||
await workspace.client.updateAgent(agentId, { name: generatedTitle });
|
||||
await expect
|
||||
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
|
||||
.poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 })
|
||||
.toBe(generatedTitle);
|
||||
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
@@ -232,8 +215,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
||||
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page, test } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
@@ -8,53 +9,13 @@ import {
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
async function installCreateAgentRequestRecorder(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const requests: unknown[] = [];
|
||||
(
|
||||
window as typeof window & {
|
||||
__paseoE2eCreateAgentRequests?: unknown[];
|
||||
}
|
||||
).__paseoE2eCreateAgentRequests = requests;
|
||||
const originalSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function (data) {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown };
|
||||
};
|
||||
if (parsed.type === "session" && parsed.message?.type === "create_agent_request") {
|
||||
requests.push(parsed.message);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON frames.
|
||||
}
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
});
|
||||
interface CreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
async function getRecordedCreateAgentCwd(page: Page, message: string): Promise<string | null> {
|
||||
return page.evaluate((expectedMessage) => {
|
||||
const requests =
|
||||
(
|
||||
window as typeof window & {
|
||||
__paseoE2eCreateAgentRequests?: Array<{
|
||||
initialPrompt?: string;
|
||||
config?: { cwd?: string };
|
||||
}>;
|
||||
}
|
||||
).__paseoE2eCreateAgentRequests ?? [];
|
||||
|
||||
for (const request of requests) {
|
||||
if (request.initialPrompt === expectedMessage) {
|
||||
return request.config?.cwd ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, message);
|
||||
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
|
||||
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
@@ -78,7 +39,14 @@ test.describe("Workspace cwd correctness", () => {
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await installCreateAgentRequestRecorder(page);
|
||||
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||
};
|
||||
});
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
@@ -97,7 +65,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 })
|
||||
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
|
||||
.toBe(workspace.repoPath);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,20 +3,14 @@ import type { WebSocketRoute } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
getVisibleWorkspaceAgentTabIds,
|
||||
expectOnlyWorkspaceAgentTabsVisible,
|
||||
@@ -37,13 +31,10 @@ import {
|
||||
expectWorkspaceDeckEntryCount,
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
page: Page,
|
||||
input: { label: string; durationMs?: number },
|
||||
@@ -88,11 +79,11 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
||||
await closeButton.first().click();
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||
async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
|
||||
return;
|
||||
@@ -166,32 +157,17 @@ test.describe("Workspace navigation regression", () => {
|
||||
});
|
||||
|
||||
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const repo = await createTempGitRepo("workspace-reconnect-");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
|
||||
workspaceIds.add(workspace.workspaceId);
|
||||
|
||||
const agent = await createIdleAgent(archiveClient, {
|
||||
cwd: repo.path,
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(agent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -231,31 +207,16 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectComposerVisible(page);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||
});
|
||||
|
||||
@@ -272,22 +233,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
});
|
||||
|
||||
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-cold-url-b-");
|
||||
const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-a-" });
|
||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-b-" });
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||
workspaceIds.add(firstWorkspace.workspaceId);
|
||||
workspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
@@ -304,45 +255,28 @@ test.describe("Workspace navigation regression", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
await secondWorkspace.cleanup();
|
||||
await firstWorkspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
|
||||
const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-a-" });
|
||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||
workspaceIds.add(firstWorkspace.workspaceId);
|
||||
workspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
const firstAgent = await createIdleAgent(archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(archiveClient, {
|
||||
cwd: secondRepo.path,
|
||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
@@ -487,16 +421,8 @@ test.describe("Workspace navigation regression", () => {
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
await secondWorkspace.cleanup();
|
||||
await firstWorkspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
} from "./helpers/archive-tab";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
@@ -14,21 +11,15 @@ test.describe("Workspace pane mounting", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
const serverId = getServerId();
|
||||
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("pane-remount-");
|
||||
let agentId: string | null = null;
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `pane-remount-${Date.now()}`,
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
@@ -54,11 +45,7 @@ test.describe("Workspace pane mounting", () => {
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findWorktreeWorkspaceForProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
test.describe("Workspace setup runtime authority", () => {
|
||||
@@ -21,7 +22,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
const repo = await createTempGitRepo("workspace-setup-chat-");
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||
@@ -48,7 +49,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
const repo = await createTempGitRepo("workspace-setup-terminal-");
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
// Create workspace via daemon API since the new workspace screen
|
||||
// no longer has a standalone terminal button
|
||||
|
||||
@@ -1,50 +1,42 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectTerminalClient();
|
||||
const repo = await createTempGitRepo("workspace-terminal-rename-");
|
||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||
terminalId: String(inner.terminalId ?? ""),
|
||||
title: String(inner.title ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
try {
|
||||
const seeded = await client.openProject(repo.path);
|
||||
if (!seeded.workspace) {
|
||||
throw new Error(seeded.error ?? "Failed to seed workspace");
|
||||
}
|
||||
const workspaceId = seeded.workspace.id;
|
||||
|
||||
const created = await client.createTerminal(repo.path);
|
||||
if (!created.terminal) {
|
||||
throw new Error(created.error ?? "Failed to create terminal");
|
||||
}
|
||||
const terminalId = created.terminal.id;
|
||||
|
||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||
terminalId: String(inner.terminalId ?? ""),
|
||||
title: String(inner.title ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -56,12 +48,9 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
|
||||
expect(renameFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = renameFrames.at(-1)!;
|
||||
expect(lastFrame.terminalId).toBe(terminalId);
|
||||
expect(lastFrame.terminalId).toBe(terminal.id);
|
||||
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.85",
|
||||
"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"
|
||||
@@ -1050,7 +1287,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
paddingVertical: 0,
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[2],
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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")} />;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
|
||||
goose:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>\n</svg>\n',
|
||||
grok: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">\n <path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>\n <path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>\n</svg>\n',
|
||||
junie:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">\n <path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>\n <rect x="5" y="15" width="10" height="10" fill="currentColor"/>\n <rect x="15" y="5" width="10" height="10" fill="currentColor"/>\n</svg>\n',
|
||||
kilo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">\n <path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>\n</svg>\n',
|
||||
|
||||
@@ -336,12 +336,50 @@ describe("parseAssistantFileLink", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute hrefs outside the workspace root", () => {
|
||||
it("allows absolute hrefs outside the workspace root", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/outside.txt", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual({
|
||||
raw: "/tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tilde hrefs as direct home-relative file targets", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("~/.paseo/plans/file-preview.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "~/.paseo/plans/file-preview.md",
|
||||
path: "~/.paseo/plans/file-preview.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("~/.paseo/plans/file-preview.md:12", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "~/.paseo/plans/file-preview.md:12",
|
||||
path: "~/.paseo/plans/file-preview.md",
|
||||
lineStart: 12,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("~\\.paseo\\plans\\file-preview.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "~\\.paseo\\plans\\file-preview.md",
|
||||
path: "~/.paseo/plans/file-preview.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects external URLs", () => {
|
||||
@@ -408,6 +446,15 @@ describe("normalizeInlinePathTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tilde paths as home-relative file targets", () => {
|
||||
expect(
|
||||
normalizeInlinePathTarget("~/.paseo/plans/file-preview.md", "/Users/test/project"),
|
||||
).toEqual({
|
||||
directory: "~/.paseo/plans",
|
||||
file: "~/.paseo/plans/file-preview.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cwd itself as the workspace root directory", () => {
|
||||
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
|
||||
directory: ".",
|
||||
|
||||
@@ -233,10 +233,7 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssistantInlinePathLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (!inlinePathTarget) {
|
||||
return null;
|
||||
@@ -247,10 +244,6 @@ function parseAssistantInlinePathLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...inlinePathTarget,
|
||||
path: normalizedPath,
|
||||
@@ -314,9 +307,7 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget;
|
||||
}
|
||||
@@ -324,7 +315,7 @@ export function parseAssistantFileLink(
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -363,10 +354,6 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
if (!lines) {
|
||||
return null;
|
||||
@@ -402,13 +389,21 @@ function parseWorkspaceRelativeFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
const parsed = parseLocalPathParts(value);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
if (isHomeRelativePath(parsed.path)) {
|
||||
return {
|
||||
raw: value,
|
||||
path: parsed.path,
|
||||
...parsed.lines,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -529,6 +524,10 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
|
||||
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
||||
}
|
||||
|
||||
function isHomeRelativePath(pathValue: string): boolean {
|
||||
return pathValue === "~" || pathValue.startsWith("~/") || pathValue.startsWith("~\\");
|
||||
}
|
||||
|
||||
function isExternalHref(value: string): boolean {
|
||||
if (value.includes("://")) {
|
||||
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
@@ -35,27 +35,22 @@ function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEn
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}> = [];
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
|
||||
async (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => {
|
||||
searches.push({
|
||||
query: input.query,
|
||||
cwd: input.cwd,
|
||||
matchMode: input.matchMode,
|
||||
limit: input.limit,
|
||||
});
|
||||
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||
},
|
||||
);
|
||||
const getDirectorySuggestions: GetDirectorySuggestions = async (input) => {
|
||||
searches.push({
|
||||
query: input.query,
|
||||
cwd: input.cwd,
|
||||
matchMode: input.matchMode,
|
||||
limit: input.limit,
|
||||
});
|
||||
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||
};
|
||||
return { getDirectorySuggestions, searches };
|
||||
}
|
||||
|
||||
const unavailableSuggestions: GetDirectorySuggestions = async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
};
|
||||
|
||||
describe("classifyForResolution", () => {
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
@@ -119,6 +114,40 @@ describe("classifyForResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps absolute paths outside the workspace as direct file targets", () => {
|
||||
const result = classifyForResolution({ href: "/tmp/outside.txt" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "/tmp/outside.txt",
|
||||
path: "/tmp/outside.txt",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tilde paths as direct file targets", () => {
|
||||
const result = classifyForResolution({ href: "~/.paseo/plans/file-preview.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "~/.paseo/plans/file-preview.md",
|
||||
path: "~/.paseo/plans/file-preview.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
@@ -193,10 +222,6 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
|
||||
it("throws a typed unresolved error when the daemon throws", async () => {
|
||||
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchDaemonResolution({
|
||||
ambiguousQuery: "dumm.md",
|
||||
@@ -208,7 +233,7 @@ describe("fetchDaemonResolution", () => {
|
||||
lineEnd: undefined,
|
||||
},
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,6 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
@@ -66,6 +65,10 @@ import {
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
||||
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
||||
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
||||
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
||||
|
||||
type SelectorView =
|
||||
| { kind: "all" }
|
||||
@@ -87,6 +90,8 @@ interface CombinedModelSelectorProps {
|
||||
}) => React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
}
|
||||
@@ -101,7 +106,8 @@ interface SelectorContentProps {
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
serverId: string | null;
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider: boolean;
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
@@ -418,26 +424,29 @@ function ProviderModelRows({
|
||||
}
|
||||
|
||||
function ProviderErrorEmptyState({
|
||||
serverId,
|
||||
providerId,
|
||||
message,
|
||||
onRetryProvider,
|
||||
isRetryingProvider,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
providerId: string;
|
||||
message: string;
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const handleRetry = useCallback(() => {
|
||||
void refresh([providerId]);
|
||||
}, [refresh, providerId]);
|
||||
onRetryProvider?.(providerId);
|
||||
}, [onRetryProvider, providerId]);
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
|
||||
{isRefreshing ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
{onRetryProvider ? (
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
{isRetryingProvider ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -452,7 +461,8 @@ function SelectorContent({
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
serverId,
|
||||
onRetryProvider,
|
||||
isRetryingProvider,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
@@ -502,9 +512,10 @@ function SelectorContent({
|
||||
if (drillSelection.kind === "error") {
|
||||
return (
|
||||
<ProviderErrorEmptyState
|
||||
serverId={serverId}
|
||||
providerId={view.providerId}
|
||||
message={drillSelection.message}
|
||||
onRetryProvider={onRetryProvider}
|
||||
isRetryingProvider={isRetryingProvider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -556,6 +567,8 @@ export function CombinedModelSelector({
|
||||
renderTrigger,
|
||||
onOpen,
|
||||
onClose,
|
||||
onRetryProvider,
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
}: CombinedModelSelectorProps) {
|
||||
@@ -630,8 +643,17 @@ export function CombinedModelSelector({
|
||||
return undefined;
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === view.providerId);
|
||||
const modelCount = provider ? getProviderModelRows(provider).length : 0;
|
||||
return Math.min(80 + modelCount * 40, 400);
|
||||
if (!provider || provider.modelSelection.kind !== "models") {
|
||||
return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT;
|
||||
}
|
||||
const modelCount = getProviderModelRows(provider).length;
|
||||
return Math.min(
|
||||
Math.max(
|
||||
DESKTOP_PROVIDER_VIEW_MIN_HEIGHT,
|
||||
DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT,
|
||||
),
|
||||
DESKTOP_PROVIDER_VIEW_MAX_HEIGHT,
|
||||
);
|
||||
}, [providers, view]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
@@ -804,7 +826,8 @@ export function CombinedModelSelector({
|
||||
onSelect={handleSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={handleDrillDown}
|
||||
serverId={serverId}
|
||||
onRetryProvider={onRetryProvider}
|
||||
isRetryingProvider={isRetryingProvider}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { diffLinePrefix } from "@/utils/diff-highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
@@ -40,6 +42,26 @@ function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
[line.type],
|
||||
);
|
||||
|
||||
const prefixStyle = React.useMemo(
|
||||
() => [
|
||||
line.type === "add" && styles.addText,
|
||||
line.type === "remove" && styles.removeText,
|
||||
line.type === "context" && styles.contextText,
|
||||
],
|
||||
[line.type],
|
||||
);
|
||||
|
||||
if (line.tokens) {
|
||||
return (
|
||||
<View style={lineContainerStyle}>
|
||||
<Text style={styles.lineText}>
|
||||
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
|
||||
<DiffTokens tokens={line.tokens} />
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={lineContainerStyle}>
|
||||
{line.segments ? (
|
||||
@@ -62,6 +84,22 @@ function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
|
||||
const keyed = React.useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{keyed.map(({ key, token }) => (
|
||||
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffSegment({
|
||||
segment,
|
||||
lineType,
|
||||
|
||||
@@ -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,14 +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 {
|
||||
@@ -45,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;
|
||||
@@ -116,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],
|
||||
@@ -130,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>
|
||||
@@ -150,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) => ({
|
||||
@@ -185,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,
|
||||
@@ -195,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),
|
||||
@@ -246,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({
|
||||
@@ -398,16 +826,26 @@ export function FilePane({
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
? resolveFilePreviewReadTarget({
|
||||
path: normalizedFilePath,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
})
|
||||
: null,
|
||||
[normalizedFilePath, normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
||||
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
|
||||
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
||||
enabled: Boolean(client && readTarget),
|
||||
queryFn: async () => {
|
||||
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
||||
if (!client || !readTarget) {
|
||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||
}
|
||||
try {
|
||||
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
|
||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
||||
const preview = await createFilePanePreview(file);
|
||||
return {
|
||||
file: preview.file,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
code: string;
|
||||
@@ -44,34 +45,6 @@ function stripTerminalFenceNewline(code: string): string {
|
||||
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
||||
}
|
||||
|
||||
// Cross-instance cache for tokenized code blocks. Tokenization is
|
||||
// theme-independent (colors are applied at render time), so the key is just
|
||||
// (language, code). Bounded by entry count — 200 is generous for a chat
|
||||
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
|
||||
class LRUCache<K, V> {
|
||||
private readonly map = new Map<K, V>();
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
else if (this.map.size >= this.max) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
|
||||
|
||||
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
code,
|
||||
language,
|
||||
@@ -87,22 +60,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
);
|
||||
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
||||
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||
const ext = fenceLanguageToExtension(language);
|
||||
if (!ext) return null;
|
||||
const cacheKey = `${ext}:${renderedCode}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let tokenizedLines: HighlightToken[][];
|
||||
try {
|
||||
tokenizedLines = highlightCode(renderedCode, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = tokenizedLines.map(toKeyedLine);
|
||||
tokenizationCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [renderedCode, language]);
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(
|
||||
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
|
||||
[renderedCode, language],
|
||||
);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -127,26 +88,6 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
);
|
||||
});
|
||||
|
||||
interface KeyedToken {
|
||||
key: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
interface KeyedLine {
|
||||
key: string;
|
||||
tokens: KeyedToken[];
|
||||
}
|
||||
|
||||
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||
return {
|
||||
key: `line-${lineIndex}`,
|
||||
tokens: tokens.map((token, tokenIndex) => ({
|
||||
key: `${lineIndex}-${tokenIndex}`,
|
||||
token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
||||
const segments: React.ReactNode[] = [];
|
||||
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
|
||||
|
||||
// Line height kept in sync with the plain `scrollText` style in
|
||||
// tool-call-details so a highlighted block lines up with an unhighlighted
|
||||
// fallback and blank lines keep their height.
|
||||
const CODE_LINE_HEIGHT = 18;
|
||||
const ZERO_WIDTH = "";
|
||||
|
||||
interface HighlightedLinesProps {
|
||||
lines: KeyedLine[];
|
||||
// 1-based line number of the first line; when set, a line-number gutter is
|
||||
// rendered (used by Read, which carries a server-normalized offset).
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
function ContentLine({ line }: { line: KeyedLine }) {
|
||||
return (
|
||||
<Text selectable style={styles.lineText}>
|
||||
{line.tokens.length === 0
|
||||
? ZERO_WIDTH
|
||||
: line.tokens.map(({ key, token }: KeyedToken) => (
|
||||
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const GutteredLine = React.memo(function GutteredLine({
|
||||
line,
|
||||
lineNumber,
|
||||
digits,
|
||||
}: {
|
||||
line: KeyedLine;
|
||||
lineNumber: number;
|
||||
digits: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.gutterText}>{String(lineNumber).padStart(digits)} </Text>
|
||||
<ContentLine line={line} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Renders pre-tokenized lines (from the shared highlight cache), optionally with
|
||||
// a line-number gutter. Callers decide whether to highlight at all, so the
|
||||
// expensive size-cap / unsupported-language fallback stays a single plain Text.
|
||||
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
if (startLine === undefined) {
|
||||
return (
|
||||
<View>
|
||||
{lines.map((line) => (
|
||||
<ContentLine key={line.key} line={line} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lastLineNumber = startLine + lines.length - 1;
|
||||
const digits = Math.max(2, String(lastLineNumber).length);
|
||||
return (
|
||||
<View>
|
||||
{lines.map((line, index) => (
|
||||
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
minHeight: CODE_LINE_HEIGHT,
|
||||
},
|
||||
gutterText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.6,
|
||||
userSelect: "none",
|
||||
flexShrink: 0,
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Image,
|
||||
type ImageStyle,
|
||||
type StyleProp,
|
||||
Text,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { deriveProjectIconColor } from "@/utils/project-icon-color";
|
||||
|
||||
const WHITE_TEXT = { color: "#ffffff" } as const;
|
||||
|
||||
export function ProjectIconView({
|
||||
iconDataUri,
|
||||
initial,
|
||||
projectKey,
|
||||
imageStyle,
|
||||
fallbackStyle,
|
||||
textStyle,
|
||||
}: {
|
||||
iconDataUri: string | null;
|
||||
initial: string;
|
||||
projectKey: string;
|
||||
imageStyle: StyleProp<ImageStyle>;
|
||||
fallbackStyle: StyleProp<ViewStyle>;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
}) {
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
const fallbackStyles = useMemo(
|
||||
() => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }],
|
||||
[fallbackStyle, projectKey],
|
||||
);
|
||||
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
|
||||
|
||||
if (iconDataUri) {
|
||||
return <Image source={imageSource} style={imageStyle} />;
|
||||
}
|
||||
return (
|
||||
<View style={fallbackStyles}>
|
||||
<Text style={textStyles}>{initial}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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 } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Image,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
StatusBar,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import {
|
||||
@@ -57,7 +57,7 @@ import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
|
||||
import {
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildProjectSettingsRoute,
|
||||
@@ -119,13 +119,6 @@ import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||
}
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
@@ -471,6 +464,7 @@ function ProjectLeadingVisual({
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
projectKey,
|
||||
chevron = null,
|
||||
showChevron = false,
|
||||
isArchiving = false,
|
||||
@@ -478,6 +472,7 @@ function ProjectLeadingVisual({
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry | null;
|
||||
projectKey: string;
|
||||
chevron?: "expand" | "collapse" | null;
|
||||
showChevron?: boolean;
|
||||
isArchiving?: boolean;
|
||||
@@ -502,7 +497,11 @@ function ProjectLeadingVisual({
|
||||
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
|
||||
<ProjectIcon
|
||||
iconDataUri={iconDataUri}
|
||||
placeholderInitial={placeholderInitial}
|
||||
projectKey={projectKey}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -511,6 +510,7 @@ function ProjectLeadingVisual({
|
||||
<ProjectLeadingVisualStatus
|
||||
iconDataUri={iconDataUri}
|
||||
placeholderInitial={placeholderInitial}
|
||||
projectKey={projectKey}
|
||||
isArchiving={isArchiving}
|
||||
shouldShowSyncedLoader={shouldShowSyncedLoader}
|
||||
activeWorkspace={activeWorkspace}
|
||||
@@ -789,30 +789,35 @@ function WorkspaceKebabMenu({
|
||||
function ProjectIcon({
|
||||
iconDataUri,
|
||||
placeholderInitial,
|
||||
projectKey,
|
||||
}: {
|
||||
iconDataUri: string | null;
|
||||
placeholderInitial: string;
|
||||
projectKey: string;
|
||||
}) {
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
if (iconDataUri) {
|
||||
return <Image source={imageSource} style={styles.projectIcon} />;
|
||||
}
|
||||
return (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
<ProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectKey={projectKey}
|
||||
imageStyle={styles.projectIcon}
|
||||
fallbackStyle={styles.projectIconFallback}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectLeadingVisualStatus({
|
||||
iconDataUri,
|
||||
placeholderInitial,
|
||||
projectKey,
|
||||
isArchiving,
|
||||
shouldShowSyncedLoader,
|
||||
activeWorkspace,
|
||||
}: {
|
||||
iconDataUri: string | null;
|
||||
placeholderInitial: string;
|
||||
projectKey: string;
|
||||
isArchiving: boolean;
|
||||
shouldShowSyncedLoader: boolean;
|
||||
activeWorkspace: SidebarWorkspaceEntry;
|
||||
@@ -852,7 +857,11 @@ function ProjectLeadingVisualStatus({
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
|
||||
<ProjectIcon
|
||||
iconDataUri={iconDataUri}
|
||||
placeholderInitial={placeholderInitial}
|
||||
projectKey={projectKey}
|
||||
/>
|
||||
{dotColorStyle ? (
|
||||
<StatusDotOverlay
|
||||
dotColorStyle={dotColorStyle}
|
||||
@@ -1244,6 +1253,7 @@ function ProjectHeaderRow({
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
projectKey={project.projectKey}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -2350,7 +2360,7 @@ export function SidebarWorkspaceList({
|
||||
const result = await client.requestProjectIcon(request.cwd);
|
||||
return result.icon;
|
||||
},
|
||||
select: toProjectIconDataUri,
|
||||
select: projectIconToDataUri,
|
||||
enabled: Boolean(
|
||||
getHostRuntimeStore().getClient(request.serverId) &&
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
|
||||
@@ -2713,13 +2723,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectIconFallbackText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: 9,
|
||||
},
|
||||
projectTitle: {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -11,9 +11,12 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
|
||||
import { HighlightedLines } from "./highlighted-content";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -143,10 +146,10 @@ function useDetailStyles(
|
||||
function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined {
|
||||
return useMemo(() => {
|
||||
if (!detail || detail.type !== "edit") return undefined;
|
||||
if (detail.unifiedDiff) {
|
||||
return parseUnifiedDiff(detail.unifiedDiff);
|
||||
}
|
||||
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
const diffLines = detail.unifiedDiff
|
||||
? parseUnifiedDiff(detail.unifiedDiff)
|
||||
: buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
return highlightDiffLines(diffLines, detail.filePath);
|
||||
}, [detail]);
|
||||
}
|
||||
|
||||
@@ -431,9 +434,22 @@ interface ScrollableContentProps {
|
||||
content: string;
|
||||
ds: DetailStyles;
|
||||
wrapInSectionFill?: boolean;
|
||||
// Drives syntax highlighting (extension only) and, with startLine, a gutter.
|
||||
filePath?: string | null;
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) {
|
||||
function ScrollableTextSection({
|
||||
content,
|
||||
ds,
|
||||
wrapInSectionFill = true,
|
||||
filePath,
|
||||
startLine,
|
||||
}: ScrollableContentProps) {
|
||||
const keyedLines = useMemo(
|
||||
() => (filePath ? highlightToKeyedLines(content, extensionFromPath(filePath)) : null),
|
||||
[content, filePath],
|
||||
);
|
||||
const body = (
|
||||
<ScrollView
|
||||
style={ds.scrollAreaFillStyle}
|
||||
@@ -447,9 +463,13 @@ function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: Scroll
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{content}
|
||||
</Text>
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{content}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -669,14 +689,27 @@ function buildDetailSections(
|
||||
return [
|
||||
<View key="write" style={ds.sectionFillStyle}>
|
||||
{detail.content ? (
|
||||
<ScrollableTextSection content={detail.content} ds={ds} wrapInSectionFill={false} />
|
||||
<ScrollableTextSection
|
||||
content={detail.content}
|
||||
ds={ds}
|
||||
wrapInSectionFill={false}
|
||||
filePath={detail.filePath}
|
||||
/>
|
||||
) : null}
|
||||
</View>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "read") {
|
||||
if (!detail.content) return [];
|
||||
return [<ScrollableTextSection key="read" content={detail.content} ds={ds} />];
|
||||
return [
|
||||
<ScrollableTextSection
|
||||
key="read"
|
||||
content={detail.content}
|
||||
ds={ds}
|
||||
filePath={detail.filePath}
|
||||
startLine={detail.offset ?? 1}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "search") {
|
||||
return buildSearchSections(detail, ds);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user