mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9496dcf22a | ||
|
|
59b01b33f1 | ||
|
|
f3b0199c13 | ||
|
|
582beeb10b | ||
|
|
6405edd8dc | ||
|
|
4b9b2fe130 | ||
|
|
4fe006df86 | ||
|
|
21e7ccb036 | ||
|
|
89727f43bc | ||
|
|
99a1d9a7a2 | ||
|
|
3c3c272925 | ||
|
|
d972f67c3f | ||
|
|
55a7590952 | ||
|
|
793b16c0df | ||
|
|
91d749f82c | ||
|
|
6376f2bf40 | ||
|
|
9d908d678b | ||
|
|
4ef4a95a42 |
@@ -33,6 +33,14 @@ paseo host add 'relay://...' --label laptop
|
||||
- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### Deprecated
|
||||
|
||||
- Deprecated plugin panel `context` and flat `agentId` props, and `addWorkspacePanel`'s `context` field (including the agent registration alias). Removal after 2027-04-23; use `params.agentId` and `addAgentPanel`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed beta file panel props to `workspaceId`, read-only `params`, `revision`, `file`, and `tab` with persisted per-tab state. Removed beta-only `location`, `PluginFileLocation`, and `PluginFileTab`.
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
|
||||
|
||||
@@ -44,6 +44,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
|
||||
+6
-6
@@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
|
||||
3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes.
|
||||
4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping.
|
||||
|
||||
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
|
||||
|
||||
@@ -34,15 +34,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
|
||||
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
|
||||
- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode
|
||||
- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated.
|
||||
|
||||
### Trust model
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
|
||||
+24
-1
@@ -79,7 +79,30 @@ Users can also detach an existing subagent from the subagents track. Detach is d
|
||||
|
||||
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
|
||||
|
||||
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
|
||||
The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt
|
||||
crash can lose queued prompts. A durable send receipt remains pending until actual dispatch
|
||||
acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued
|
||||
admission within their original timeout and cancellation scope.
|
||||
|
||||
Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module
|
||||
persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses
|
||||
`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt
|
||||
acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch
|
||||
accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same
|
||||
caller, child, and host into one watch.
|
||||
|
||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
||||
reporting after detachment.
|
||||
|
||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||
retries the captured notification; it does not change the child's reported outcome. Remote host loss
|
||||
follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining
|
||||
outstanding intent. A crash after successful dispatch but before removal commits can repeat the
|
||||
notification. Clean shutdown can also flush an already queued callback while retaining its intent,
|
||||
so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery.
|
||||
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
|
||||
@@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store.
|
||||
Workspace descriptors, checkout diff subscriptions, and script metadata read the last known
|
||||
snapshot from that store, so reconnecting clients receive it before refresh completes.
|
||||
Refcounted git watches feed the store through snapshot-updated events; their lifetime controls
|
||||
refresh only. Session cleanup and workspace archive retain the data. Removing the final active
|
||||
workspace for a directory clears it. The store lives in memory and resets on daemon restart.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
@@ -175,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
|
||||
- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
|
||||
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
|
||||
|
||||
+19
-4
@@ -44,10 +44,25 @@ connection candidates, including direct connections and routes discovered throug
|
||||
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
|
||||
the destination workspace keeps its existing disconnected or reconnecting behavior.
|
||||
|
||||
Finish notifications use the existing idle wait, including a pending initial run or an already idle
|
||||
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
|
||||
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
|
||||
no additional turn attribution or delivery guarantee.
|
||||
Finish notifications name the destination server and child agent. The caller's daemon owns durable
|
||||
subscription intent and restores observation through the same routed wait and permission observation
|
||||
used during ordinary operation. Restarting the caller's daemon does not imply that a remote child
|
||||
stopped. Three failed remote observation attempts end the subscription with a notification that the
|
||||
child could not be observed and its status is unknown. This does not prove the host is offline: remote
|
||||
RPC failures also prevent observation. Attempts use capped backoff; a connection still
|
||||
in progress does not spend that budget. A confirmed removed registration reports host unavailability
|
||||
immediately through the same path. Neither notice claims the child finished. Intent remains stored
|
||||
until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The
|
||||
parent decides whether to inspect the child or delegate again.
|
||||
|
||||
A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt
|
||||
response retains intent because the destination may have admitted it; observation then follows the
|
||||
same retry policy. A confirmed missing child reports unavailability.
|
||||
|
||||
Remote creation and local subscription persistence are separate commits. A failed local write reports
|
||||
failure even if the destination already created the child; inspect it before retrying creation.
|
||||
See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment,
|
||||
and duplicate-delivery semantics. Concurrent prompts gain no turn attribution.
|
||||
|
||||
## Connections and recovery
|
||||
|
||||
|
||||
@@ -43,13 +43,16 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
|
||||
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
|
||||
editing sessions, caches, file-link resolution and supported navigation context without moving
|
||||
their providers. Keep this capability at the workspace boundary; new sheet callers must not
|
||||
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
|
||||
forward the same Gorhom modal queue; neither creates another stack. See
|
||||
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
|
||||
+34
-18
@@ -115,11 +115,10 @@ quietly relying on:
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
|
||||
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
|
||||
translate/padding styles. The composer and chat content must both read that
|
||||
provider-owned value. A portal'd popover is outside the composer tree — it
|
||||
does not get that transform unless you apply it yourself.
|
||||
- **Transforms.** The composer moves with the keyboard through a
|
||||
`KeyboardTranslateView` transform. A portal'd popover is outside that view, so
|
||||
it does not move with the composer unless it renders its own
|
||||
`KeyboardTranslateView` with the same translation.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
@@ -158,7 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, settled stream inset, and explorer padding
|
||||
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
@@ -256,11 +255,27 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native
|
||||
animation-end events because the controller can retain a nonzero value after
|
||||
the keyboard closes.
|
||||
|
||||
Measure portal anchors while the dock is at rest. If a popover opens during
|
||||
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
|
||||
dock's current layout and transform. Snapshot the shift at measurement time and
|
||||
apply only the subsequent shift delta to the animated `bottom`. Adding the full
|
||||
shift moves the portal twice and can place it over the composer controls.
|
||||
Anchor a portal to content inside a `KeyboardTranslateView` with
|
||||
`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the
|
||||
translation scope is React context and does not cross it (Gotcha 7). It returns:
|
||||
|
||||
- `measureAtRest(element)`: the element's window rect with the keyboard
|
||||
translation left out, or null once the anchor unmounts. Lay the popover out
|
||||
from this rect. It assumes the translate view's parent does not move with the
|
||||
keyboard, which holds for the composer dock.
|
||||
- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so
|
||||
the popover moves with the anchor on the same driver, including centered
|
||||
clearance and disabled translation.
|
||||
- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's
|
||||
height with it; never animate height per frame.
|
||||
|
||||
Do not measure an anchor with `measureInWindow` and correct it by the keyboard
|
||||
shift. On iOS the translate view uses the native driver, and `measureInWindow`
|
||||
includes its transform only when the view happened to re-render since the
|
||||
keyboard moved. Both results were observed, so any correction double-lifts the
|
||||
popover or leaves it behind the keyboard. `measureAtRest` measures through
|
||||
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
|
||||
settles; the composer's resting position changes as the draft grows.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
@@ -273,7 +288,8 @@ not be required to repair the offset.
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
1. Measure the anchor with `measureInWindow`.
|
||||
1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it
|
||||
moves with the keyboard (Gotcha 3).
|
||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||
3. Position with anchor coordinates relative to the host:
|
||||
|
||||
@@ -347,10 +363,10 @@ track phase.
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
|
||||
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
|
||||
close. This includes native centered dialogs: separate React Native Modals cannot present a
|
||||
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
|
||||
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
|
||||
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
|
||||
portal host and container measurement while the sheet still joins the same stack. That export is
|
||||
root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host
|
||||
and container measurement while forwarding the same modal queue. Compact
|
||||
[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars;
|
||||
wide layouts keep local geometry. That export is
|
||||
what `patches/@gorhom+bottom-sheet+*.patch` adds.
|
||||
|
||||
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
|
||||
@@ -405,8 +421,8 @@ Before you write a new one, ask:
|
||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||
raw window coordinates as local Portal coordinates.
|
||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||
If no, you can probably skip the transform entirely.
|
||||
yes, anchor it with `useKeyboardAnchor()` and wrap it in a
|
||||
`KeyboardTranslateView` with the anchor's translation (Gotcha 3).
|
||||
5. **Will the panel's content height vary?** If yes, you need both
|
||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||
|
||||
@@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
|
||||
+31
-1
@@ -53,7 +53,18 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
|
||||
tab, and New workspace. It checks empty-content taps and header taps at the cap
|
||||
without changing the input height. Each
|
||||
host also opens command/file autocomplete, the model selector, the attachment
|
||||
menu, and the forge picker from both keyboard states. It then submits a long
|
||||
menu, and the forge picker from both keyboard states. Autocomplete opens with
|
||||
the keyboard closed, before the keyboard opens, and after it settles. Each time
|
||||
the expected best match must be drawn whole between the header and the composer,
|
||||
above the IME frame, and tapping it there must produce the exact composer text.
|
||||
Accessibility reports rows behind the keyboard too, so
|
||||
`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares
|
||||
bounds. Android leaves the chat and composer out of the accessibility tree while
|
||||
suggestions are open, so the composer bounds come from a snapshot with an empty
|
||||
input in the same keyboard state. The queries are partial names with known best
|
||||
matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts
|
||||
`/release` finds this repository's `release-beta` skill for the first Claude model,
|
||||
and `@app` finds `packages/app`. It then submits a long
|
||||
fixture from each host and checks native stream displacement twice: after the
|
||||
response settles, and after returning to the bottom and letting the scrollbar
|
||||
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
|
||||
@@ -64,6 +75,25 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
To rerun only the autocomplete, model selector, attachment menu, and forge picker
|
||||
checks on each host, skipping the chat baseline and the growth, dismissal, and
|
||||
stream scenarios:
|
||||
|
||||
```bash
|
||||
PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android
|
||||
```
|
||||
|
||||
`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same
|
||||
autocomplete checks on iOS for chat, a workspace draft tab, and New workspace,
|
||||
with commands, files, and files under a grown draft. Suggestions open while the
|
||||
keyboard opens (`fill` taps and types at once), after it settles, with it closed,
|
||||
and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert
|
||||
accessibility state and cannot compare bounds. Run it where `agent-device`
|
||||
reaches the simulator, with the development app connected to the fixture host
|
||||
and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
|
||||
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
|
||||
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
|
||||
`.dev/agent-device-artifacts/composer-keyboard-ios`.
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
|
||||
+1
-1
@@ -570,7 +570,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th
|
||||
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
|
||||
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
|
||||
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
|
||||
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
|
||||
editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place:
|
||||
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
|
||||
make that panel faster instead. Source highlights a large file on native in idle-time slices,
|
||||
the lines around the opening line first.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Relay endpoint security
|
||||
|
||||
The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`.
|
||||
|
||||
Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice.
|
||||
|
||||
## Two modes
|
||||
|
||||
| Mode | Construction | Replay protection |
|
||||
| ----------- | ------------------------------------------------ | -------------------------------- |
|
||||
| `legacy` | X25519 + NaCl `box`, random nonces | none |
|
||||
| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral |
|
||||
|
||||
Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged.
|
||||
|
||||
The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction.
|
||||
|
||||
## Noise mapping
|
||||
|
||||
- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity.
|
||||
- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`.
|
||||
- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`.
|
||||
- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: <base64> } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters.
|
||||
- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: <base64> } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated.
|
||||
- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake.
|
||||
- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies.
|
||||
|
||||
Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon.
|
||||
|
||||
## Records
|
||||
|
||||
Each application frame becomes one record of one or more Noise transport messages:
|
||||
|
||||
```
|
||||
chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
|
||||
wire record = (u16 BE ciphertextLength || ciphertext)+
|
||||
```
|
||||
|
||||
`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`.
|
||||
|
||||
The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this.
|
||||
|
||||
Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated.
|
||||
|
||||
## Retries and failures
|
||||
|
||||
- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored.
|
||||
- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart.
|
||||
- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted.
|
||||
- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`.
|
||||
- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing.
|
||||
- Keys are cleared on close as a best effort.
|
||||
|
||||
## Negotiation and admission
|
||||
|
||||
The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it.
|
||||
|
||||
The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it.
|
||||
|
||||
The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online.
|
||||
|
||||
Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted.
|
||||
|
||||
The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update.
|
||||
|
||||
The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known.
|
||||
|
||||
## Enforcement flag
|
||||
|
||||
`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart.
|
||||
|
||||
- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused.
|
||||
- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake.
|
||||
- Direct and local connections are outside the flag.
|
||||
|
||||
Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date.
|
||||
|
||||
## Known limitation
|
||||
|
||||
While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level.
|
||||
- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule.
|
||||
- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts.
|
||||
- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag.
|
||||
- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg=
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
|
||||
Generated
+42
@@ -8805,6 +8805,45 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
|
||||
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -42184,6 +42223,9 @@
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0-beta.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Compact Add project context", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("canceling Add project preserves the sidebar", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await openAddProjectFlow(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
});
|
||||
test("successful Add project reveals the new workspace page", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName);
|
||||
await page.keyboard.press("Enter");
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: projectPickerFixture.projectName,
|
||||
projectPath: projectPickerFixture.projectPath,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,10 @@ async function completeSubmittedTurn(
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toBeVisible();
|
||||
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
|
||||
@@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
expectCommandCenterScrollToStayAt,
|
||||
expectKeyboardNavigationToResume,
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -137,3 +143,94 @@ test("unscoped command search keeps commands visible and reports file-search fai
|
||||
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-delayed-scroll-",
|
||||
title: "Delayed file search scroll",
|
||||
repo: { files: paseoSizedFiles() },
|
||||
});
|
||||
try {
|
||||
const responses = await holdDirectorySuggestionResponses(page);
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await panel.getByTestId("command-center-input").fill("index");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
responses.hold();
|
||||
await panel.getByTestId("command-center-input").fill("index.");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await responses.release();
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expectCommandCenterScrollToStayAt(panel, offset);
|
||||
await expectKeyboardNavigationToResume(page, panel, offset);
|
||||
|
||||
const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await panel.getByTestId("command-center-input").fill("index.ts");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expect
|
||||
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
|
||||
.toBeLessThan(nextOffset);
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+P");
|
||||
await expect(panel.getByTestId("command-center-input")).toHaveValue("");
|
||||
await panel.getByTestId("command-center-input").fill("index");
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Compact file search context", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("a file result opens a sheet above the left sidebar", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "compact-search-file-",
|
||||
title: "Compact file lookup",
|
||||
repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] },
|
||||
});
|
||||
try {
|
||||
await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
const search = await openCommandCenter(page);
|
||||
await search.getByTestId("command-center-input").fill("lookup.ts");
|
||||
await search.getByRole("button", { name: "lookup.ts", exact: true }).click();
|
||||
await expect(page.getByTestId("sheet-tab-body")).toBeVisible();
|
||||
await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts");
|
||||
await page
|
||||
.getByTestId("sheet-tab-header")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
seedSearchableCommandCenter,
|
||||
observeCommandCenterRowRenders,
|
||||
searchCommandCenter,
|
||||
warmCommandCenterRows,
|
||||
expectOnlyCommandCenterRowRendered,
|
||||
expectCommandCenterAgentWindow,
|
||||
scrollToOldestCommandCenterAgent,
|
||||
} from "../support/helpers/command-center-performance";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectCommandCenterToRemainAtTheTop,
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
expectCommandCenterScrollToStayAt,
|
||||
expectKeyboardNavigationToResume,
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
@@ -16,12 +30,15 @@ test.use({
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("opening the command center keeps its first result fully visible", async ({ page }) => {
|
||||
test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-scroll-",
|
||||
title: "Command Center Scroll 00",
|
||||
});
|
||||
|
||||
let newestWorkspaceId = seeded.workspaceId;
|
||||
try {
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
@@ -36,6 +53,7 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
|
||||
}
|
||||
newestWorkspaceId = created.workspace.id;
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -48,6 +66,52 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
|
||||
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
|
||||
await expectCommandCenterScrollToStayAt(panel, offset);
|
||||
await expectKeyboardNavigationToResume(page, panel, offset);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("live search updates render only the changed row and keep agents virtualized", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedSearchableCommandCenter();
|
||||
try {
|
||||
await observeCommandCenterRowRenders(page);
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenterWithKeyboard(page);
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
|
||||
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
|
||||
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`agent:${getServerId()}:${seeded.agents[59].id}`,
|
||||
);
|
||||
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await warmCommandCenterRows(page);
|
||||
await scrollToOldestCommandCenterAgent(panel);
|
||||
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
@@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries";
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent and workspace results retain their icons and overlay live status", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "command-center-status-",
|
||||
title: "Review checkout retries",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "Keep working on checkout retries",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectAgentTabActive(page, workspace.agentId);
|
||||
const panel = await openCommandCenter(page);
|
||||
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
|
||||
await workspace.client.cancelAgent(workspace.agentId);
|
||||
await expect(agent.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(row.getByRole("progressbar")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace results show their title, host, and branch and open the workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -156,3 +185,74 @@ test.describe("Command center workspaces", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Compact sidebar inspection", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
async function openSidebar(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectSidebarPreserved(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
}
|
||||
|
||||
test("canceling Search retains the compact sidebar", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSidebar(page);
|
||||
await openCommandCenter(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
|
||||
await expectSidebarPreserved(page);
|
||||
});
|
||||
test("canceling Import session retains the compact sidebar", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSidebar(page);
|
||||
await page
|
||||
.getByTestId("sidebar-project-empty-state")
|
||||
.getByRole("button", { name: "Import session", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("import-session-sheet")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByTestId("import-session-sheet")).not.toBeVisible();
|
||||
await expectSidebarPreserved(page);
|
||||
});
|
||||
|
||||
test("Search reveals the same workspace and a selected root agent", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "compact-search-destination-",
|
||||
title: "Compact destination",
|
||||
});
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Compact root agent",
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
|
||||
await openSidebar(page);
|
||||
const search = await openCommandCenter(page);
|
||||
await search.getByTestId("command-center-input").fill("Compact destination");
|
||||
await search
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
|
||||
await openSidebar(page);
|
||||
const agentSearch = await openCommandCenter(page);
|
||||
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
|
||||
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent...", exact: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
|
||||
async function pressInComposer(page: Page, key: string) {
|
||||
await composerLocator(page).press(key);
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
|
||||
await test.step("the selected row imports and opens the hydrated transcript", async () => {
|
||||
await flow.importSession(scenario.importSessionId);
|
||||
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "08-mobile-agent-after-import.png");
|
||||
});
|
||||
|
||||
|
||||
@@ -105,6 +105,17 @@ async function selectFileView(page: Page, view: "Preview" | "Source") {
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") {
|
||||
const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true });
|
||||
await button.click();
|
||||
await page.reload();
|
||||
if (view === "Source") {
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
} else {
|
||||
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
|
||||
async function pdfViewer(page: Page): Promise<Frame> {
|
||||
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
|
||||
@@ -220,6 +231,9 @@ test("directory examples mount the PDF frame and write Markdown through the file
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
});
|
||||
|
||||
await rememberMarkdownViewAcrossReload(page, "Preview");
|
||||
await rememberMarkdownViewAcrossReload(page, "Source");
|
||||
|
||||
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
|
||||
// cursor and vim mode in the panel's toolbar.
|
||||
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
|
||||
|
||||
@@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
/^Grouping/,
|
||||
/^Sort/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
expectCompactSidebarOpen,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
|
||||
@@ -90,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
|
||||
await openAddProjectFlow(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await expectCompactSidebarOpen(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await leaveSettings(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await expectFooterIconRow(page);
|
||||
await footerScreenshot(page, "footer-compact-with-rows");
|
||||
|
||||
@@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
/^Grouping/,
|
||||
/^Sort/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function rowTestIds(rows: Locator) {
|
||||
@@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond(
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
|
||||
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
||||
const before = await rowTestIds(rows);
|
||||
const source = await visibleBoundingBox(rows.nth(0));
|
||||
const target = await visibleBoundingBox(rows.nth(1));
|
||||
const page = rows.page();
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
||||
}
|
||||
|
||||
test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
@@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
|
||||
);
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
const ordinaryRows = page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
);
|
||||
await expect
|
||||
.poll(() => rowTestIds(ordinaryRows))
|
||||
.toEqual([secondWorkspaceTestId, firstWorkspaceTestId]);
|
||||
await dragWithoutReordering(ordinaryRows);
|
||||
|
||||
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
|
||||
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
|
||||
@@ -108,3 +122,243 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
|
||||
await secondProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function reconnectSidebar(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
}
|
||||
|
||||
async function expectNoWorkspaceSortChoice(page: Page): Promise<void> {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function promptWorkspace(seed: Awaited<ReturnType<typeof seedWorkspace>>, agentId: string) {
|
||||
await seed.client.sendAgentMessage(agentId, "sidebar ordering check");
|
||||
expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle");
|
||||
}
|
||||
|
||||
test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" });
|
||||
try {
|
||||
const newer = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: "Newer workspace",
|
||||
});
|
||||
if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed");
|
||||
const agent = await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
title: "Ordering agent",
|
||||
});
|
||||
const newerAgent = await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: newer.workspace.id,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
title: "Newer agent",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`;
|
||||
const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`;
|
||||
const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await promptWorkspace(seed, agent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await promptWorkspace(seed, newerAgent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await promptWorkspace(seed, agent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await seed.client.archiveAgent(agent.id);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await reconnectSidebar(page);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expectNoWorkspaceSortChoice(page);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function seedCompactProject() {
|
||||
const seed = await seedWorkspace({
|
||||
repoPrefix: "sidebar-visibility-",
|
||||
title: "Oldest workspace",
|
||||
});
|
||||
const ids = [seed.workspaceId];
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: `Workspace ${index}`,
|
||||
});
|
||||
if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed");
|
||||
ids.unshift(created.workspace.id);
|
||||
}
|
||||
const agent = await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
title: "Overflow agent",
|
||||
});
|
||||
return { seed, ids, agent };
|
||||
}
|
||||
|
||||
async function expectWorkspaceOrder(rows: Locator, ids: string[]) {
|
||||
await expect
|
||||
.poll(() => rowTestIds(rows))
|
||||
.toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`));
|
||||
}
|
||||
|
||||
async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") {
|
||||
await list.getByLabel(action, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function selectProjectGrouping(page: Page) {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Group/ }).click();
|
||||
await page.getByTestId("sidebar-grouping-project").click();
|
||||
}
|
||||
|
||||
type CompactProject = Awaited<ReturnType<typeof seedCompactProject>>;
|
||||
|
||||
function compactProjectRows(page: Page, seed: CompactProject["seed"]) {
|
||||
const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`);
|
||||
return {
|
||||
rows: list.locator('[data-testid^="sidebar-workspace-row-"]'),
|
||||
group: list.locator(".."),
|
||||
project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) {
|
||||
const { rows } = compactProjectRows(page, seed);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
|
||||
await seed.client.sendAgentMessage(agent.id, "show active overflow");
|
||||
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
|
||||
await seed.client.waitForFinish(agent.id, 15_000);
|
||||
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
|
||||
await seed.client.clearWorkspaceAttention(seed.workspaceId);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
}
|
||||
|
||||
async function expectRevealSurvivesSortingAndCollapse(
|
||||
page: Page,
|
||||
{ seed, ids, agent }: CompactProject,
|
||||
) {
|
||||
const { rows, group, project } = compactProjectRows(page, seed);
|
||||
const retainedRows = await rows.elementHandles();
|
||||
await revealWorkspaces(group, "Show more");
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
for (const [index, retained] of retainedRows.entries()) {
|
||||
expect(
|
||||
await rows.nth(index).evaluate((current, previous) => current === previous, retained),
|
||||
).toBe(true);
|
||||
}
|
||||
await project.click();
|
||||
await expect(rows).toHaveCount(0);
|
||||
await project.click();
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await seed.client.sendAgentMessage(agent.id, "expanded overflow");
|
||||
await seed.client.waitForFinish(agent.id, 15_000);
|
||||
await seed.client.clearWorkspaceAttention(seed.workspaceId);
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]);
|
||||
const revealedRow = await rows.nth(8).elementHandle();
|
||||
await revealWorkspaces(group, "Show less");
|
||||
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false);
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
}
|
||||
|
||||
async function expectStatusBucketsRetainActiveOverflow(
|
||||
page: Page,
|
||||
{ seed, ids, agent }: CompactProject,
|
||||
) {
|
||||
const { rows } = compactProjectRows(page, seed);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
const done = page.getByTestId("sidebar-status-group-rows-done");
|
||||
const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]');
|
||||
await expect(doneRows).toHaveCount(7);
|
||||
await revealWorkspaces(done, "Show more");
|
||||
await expect(doneRows).toHaveCount(12);
|
||||
await seed.client.sendAgentMessage(agent.id, "status overflow");
|
||||
await seed.client.waitForFinish(agent.id, 15_000);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("sidebar-status-group-rows-attention")
|
||||
.getByText("Oldest workspace", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(doneRows).toHaveCount(11);
|
||||
await seed.client.clearWorkspaceAttention(seed.workspaceId);
|
||||
await expect(doneRows).toHaveCount(12);
|
||||
await revealWorkspaces(done, "Show less");
|
||||
await expect(doneRows).toHaveCount(7);
|
||||
const activeAgents: Array<{ id: string }> = [];
|
||||
for (const workspaceId of ids.slice(0, 8)) {
|
||||
activeAgents.push(
|
||||
await seed.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
cwd: seed.repoPath,
|
||||
workspaceId,
|
||||
title: "Active overflow agent",
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")),
|
||||
);
|
||||
await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000)));
|
||||
const attention = page.getByTestId("sidebar-status-group-rows-attention");
|
||||
await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8);
|
||||
await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0);
|
||||
for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId);
|
||||
await expect(doneRows).toHaveCount(7);
|
||||
await selectProjectGrouping(page);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
}
|
||||
|
||||
test("compact groups retain active overflow and reveal the current ordered rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const fixture = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await test.step("Active overflow appears without intervening Done rows", () =>
|
||||
expectActiveOverflowTransitions(page, fixture));
|
||||
await test.step("Reveal follows current sort and survives collapse", () =>
|
||||
expectRevealSurvivesSortingAndCollapse(page, fixture));
|
||||
await test.step("Status reveals Done rows and keeps every active row", () =>
|
||||
expectStatusBucketsRetainActiveOverflow(page, fixture));
|
||||
} finally {
|
||||
await fixture.seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
|
||||
test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, {
|
||||
publishedVersion: "0.8.0",
|
||||
});
|
||||
try {
|
||||
const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port });
|
||||
try {
|
||||
const second = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Second old workspace",
|
||||
});
|
||||
expect(second.workspace).toBeTruthy();
|
||||
await openPublishedHost(page, daemon, project.workspaceId);
|
||||
await openWorkspace(page, daemon.serverId, second.workspace!.id);
|
||||
await expectSortingRequiresHostUpdate(page);
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expectStatusHasNoSorting(page);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openPublishedHost(
|
||||
page: Page,
|
||||
daemon: { serverId: string; port: number },
|
||||
workspaceId: string,
|
||||
) {
|
||||
const host = buildSeededHost({
|
||||
serverId: daemon.serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript((seededHost) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem(
|
||||
"sidebar-view",
|
||||
JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }),
|
||||
);
|
||||
}, host);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, serverId: string, workspaceId: string) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(workspaceId));
|
||||
}
|
||||
|
||||
async function expectSortingRequiresHostUpdate(page: Page) {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectStatusHasNoSorting(page: Page) {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
|
||||
}
|
||||
@@ -180,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) {
|
||||
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, workspace);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
|
||||
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
|
||||
@@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => {
|
||||
setTimeout(finish, 700);
|
||||
`;
|
||||
|
||||
// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
|
||||
// bracketed paste, and echoes typed lines, so the PTY output proves what reached
|
||||
// the terminal and where the file was read from.
|
||||
const FILE_RECEIVER_SCRIPT = String.raw`
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
let buffer = "";
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
|
||||
}
|
||||
buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
|
||||
}
|
||||
if (buffer.includes("\x03")) {
|
||||
process.stdout.write("INTERRUPTED\r\n");
|
||||
buffer = buffer.replaceAll("\x03", "");
|
||||
}
|
||||
const enter = buffer.indexOf("\r");
|
||||
if (enter >= 0 && !buffer.includes("'")) {
|
||||
process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
|
||||
buffer = buffer.slice(enter + 1);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
function pngBytes(seed: number): Buffer {
|
||||
return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
|
||||
}
|
||||
|
||||
function fileRead(bytes: Buffer, location: "upload" | "local"): string {
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
return `FILE_READ:${digest}:${location}`;
|
||||
}
|
||||
|
||||
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
|
||||
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
|
||||
function terminalSurface(page: Page) {
|
||||
return page.getByTestId("terminal-surface").filter({ visible: true });
|
||||
}
|
||||
|
||||
function terminalInput(page: Page) {
|
||||
return terminalSurface(page).locator(".xterm-helper-textarea");
|
||||
}
|
||||
|
||||
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
|
||||
await terminalInput(page).evaluate(
|
||||
(element, data) => {
|
||||
(element as HTMLTextAreaElement).focus();
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
|
||||
);
|
||||
},
|
||||
[...bytes],
|
||||
);
|
||||
}
|
||||
|
||||
interface DroppedFile {
|
||||
name: string;
|
||||
type: string;
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
async function dragTransfer(page: Page, files: DroppedFile[]) {
|
||||
return await page.evaluateHandle(
|
||||
(entries) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
for (const entry of entries) {
|
||||
dataTransfer.items.add(
|
||||
new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
|
||||
);
|
||||
}
|
||||
return dataTransfer;
|
||||
},
|
||||
files.map((file) => ({ ...file, bytes: [...file.bytes] })),
|
||||
);
|
||||
}
|
||||
|
||||
async function dragFilesOver(page: Page, files: DroppedFile[]): Promise<void> {
|
||||
const dataTransfer = await dragTransfer(page, files);
|
||||
await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
|
||||
await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
|
||||
const dataTransfer = await dragTransfer(page, files);
|
||||
await terminalInput(page).dispatchEvent("drop", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
/** The shared "Drop files here" overlay of the zone around the terminal. */
|
||||
function terminalDropOverlay(page: Page) {
|
||||
return terminalSurface(page)
|
||||
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
|
||||
.getByTestId("file-drop-backdrop");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
|
||||
* files resolve to paths inside `directory` instead of uploading.
|
||||
*/
|
||||
async function typeLine(page: Page, text: string): Promise<void> {
|
||||
await terminalInput(page).pressSequentially(text);
|
||||
await terminalInput(page).press("Enter");
|
||||
}
|
||||
|
||||
test.describe("Terminal protocol queries", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
|
||||
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
|
||||
await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => {
|
||||
await harness.killTerminal(terminalInstance.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function openFileReceiver(page: Page, name: string) {
|
||||
const terminal = await harness.createTerminal({
|
||||
name,
|
||||
command: process.execPath,
|
||||
args: ["file-receiver.cjs"],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// Reads the PTY itself, so output from one pane cannot satisfy another's check.
|
||||
async function terminalOutput(terminalId: string): Promise<string> {
|
||||
const capture = await harness.client.captureTerminal(terminalId);
|
||||
return capture.lines.join("").replace(/\s/g, "");
|
||||
}
|
||||
|
||||
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondPage = await page.context().newPage();
|
||||
const first = await openFileReceiver(page, "image-paste-a");
|
||||
const second = await openFileReceiver(secondPage, "image-paste-b");
|
||||
try {
|
||||
await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
|
||||
|
||||
await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
|
||||
await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
|
||||
expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
|
||||
expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
|
||||
} finally {
|
||||
await secondPage.close();
|
||||
await harness.killTerminal(first.id);
|
||||
await harness.killTerminal(second.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote");
|
||||
const files = [
|
||||
{ name: "shot.png", type: "image/png", bytes: pngBytes(3) },
|
||||
{ name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
|
||||
];
|
||||
try {
|
||||
await dragFilesOver(page, files);
|
||||
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
|
||||
await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
|
||||
|
||||
await dropFiles(page, files);
|
||||
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
|
||||
const files = [
|
||||
{ name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
|
||||
{ name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
|
||||
];
|
||||
for (const file of files) {
|
||||
await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
|
||||
}
|
||||
await installLocalDesktopHost(page, harness.tempRepo.path);
|
||||
const terminal = await openFileReceiver(page, "file-drop-local");
|
||||
try {
|
||||
await dropFiles(page, files);
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
|
||||
expect(await terminalOutput(terminal.id)).not.toContain(":upload");
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
|
||||
const upload = await holdFileUploadResponse(page);
|
||||
const terminal = await openFileReceiver(page, "image-order");
|
||||
try {
|
||||
await pasteImage(page, pngBytes(4));
|
||||
await upload.waitUntilHeld();
|
||||
await pasteImage(page, pngBytes(5));
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
|
||||
|
||||
await typeLine(page, "describe");
|
||||
await expectTerminalOutput(terminal.id, "TYPED:describe");
|
||||
|
||||
upload.release();
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
|
||||
const output = await terminalOutput(terminal.id);
|
||||
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
|
||||
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
|
||||
output.indexOf(fileRead(pngBytes(5), "upload")),
|
||||
);
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
|
||||
const upload = await holdFileUploadResponse(page);
|
||||
const terminal = await openFileReceiver(page, "image-cancel");
|
||||
try {
|
||||
await pasteImage(page, pngBytes(6));
|
||||
await upload.waitUntilHeld();
|
||||
|
||||
await terminalInput(page).press("Control+c");
|
||||
await expectTerminalOutput(terminal.id, "INTERRUPTED");
|
||||
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
|
||||
upload.release();
|
||||
|
||||
await pasteImage(page, pngBytes(7));
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
|
||||
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("a disconnect during upload reports one failure and input keeps working", async ({
|
||||
page,
|
||||
}) => {
|
||||
const upload = await holdFileUploadResponse(page);
|
||||
const terminal = await openFileReceiver(page, "image-disconnect");
|
||||
try {
|
||||
await pasteImage(page, pngBytes(8));
|
||||
await upload.waitUntilHeld();
|
||||
|
||||
await upload.disconnect();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(
|
||||
"Failed to paste file: Host is not connected",
|
||||
);
|
||||
|
||||
await typeLine(page, "after");
|
||||
await expectTerminalOutput(terminal.id, "TYPED:after");
|
||||
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
@@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
await revealSidebarProjectWorkspaces(page, project.projectKey);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
@@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
||||
await expectHiddenWorkspaceShortcuts(page, order);
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
await page.getByLabel("Show less", { exact: true }).click();
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expect(rows).toHaveCount(7);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
|
||||
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
|
||||
# Hold past drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
|
||||
@@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
|
||||
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
|
||||
BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')"
|
||||
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
|
||||
# `overlays` runs only each host's autocomplete, model selector, attachment menu, and
|
||||
# forge picker checks; `all` also runs the chat baseline and the full host scenario.
|
||||
SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}"
|
||||
if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then
|
||||
echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${SERVER_ID}" ]]; then
|
||||
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
|
||||
@@ -173,8 +180,10 @@ sleep 3
|
||||
|
||||
# Create the fixture while the client is connected so its directory replica observes the insert.
|
||||
ad wait 'id="message-input-root"' 90000
|
||||
# The compact header names the workspace, not the agent, so the chat waits below find
|
||||
# the fixture by its prompt bubble.
|
||||
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
|
||||
npm run --silent cli -- run "${AGENT_TITLE}" \
|
||||
--background \
|
||||
--title "${AGENT_TITLE}" \
|
||||
--provider mock \
|
||||
@@ -206,238 +215,241 @@ clear_input
|
||||
ad keyboard dismiss || true
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
|
||||
capture_screen "${ARTIFACTS_DIR}/baseline.png"
|
||||
read -r input_x input_y baseline_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
|
||||
)
|
||||
read -r model_x model_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
|
||||
)
|
||||
read -r attach_x attach_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
|
||||
)
|
||||
read -r _ header_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
|
||||
)
|
||||
# Chat-only invariants, then the long-draft checks on New workspace.
|
||||
run_chat_baseline() {
|
||||
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
|
||||
capture_screen "${ARTIFACTS_DIR}/baseline.png"
|
||||
read -r input_x input_y baseline_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
|
||||
)
|
||||
read -r model_x model_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
|
||||
)
|
||||
read -r attach_x attach_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
|
||||
)
|
||||
read -r _ header_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
|
||||
)
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
model_open_y="$((model_y - keyboard_shift))"
|
||||
adb shell input tap "${model_x}" "${model_open_y}"
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell input tap "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
ad wait 'editable=true' 10000
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
model_open_y="$((model_y - keyboard_shift))"
|
||||
adb shell input tap "${model_x}" "${model_open_y}"
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell input tap "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
|
||||
node "${ASSERT}" above-y \
|
||||
"${ARTIFACTS_DIR}/command-popover.json" \
|
||||
"/exit, Archive the current agent" \
|
||||
"$((input_y - baseline_input_height / 2))"
|
||||
adb shell input keyevent 67
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
|
||||
node "${ASSERT}" above-y \
|
||||
"${ARTIFACTS_DIR}/command-popover.json" \
|
||||
"/exit, Archive the current agent" \
|
||||
"$((input_y - baseline_input_height / 2))"
|
||||
adb shell input keyevent 67
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
|
||||
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/multiline-first.json" \
|
||||
"${ARTIFACTS_DIR}/multiline-second.json"
|
||||
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
|
||||
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/multiline-first.json" \
|
||||
"${ARTIFACTS_DIR}/multiline-second.json"
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
|
||||
|
||||
node "${ASSERT}" same-header \
|
||||
"${ARTIFACTS_DIR}/baseline.png" \
|
||||
"${ARTIFACTS_DIR}/keyboard-open.png" \
|
||||
"$((header_y + 48))"
|
||||
node "${ASSERT}" same-header \
|
||||
"${ARTIFACTS_DIR}/baseline.png" \
|
||||
"${ARTIFACTS_DIR}/keyboard-open.png" \
|
||||
"$((header_y + 48))"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-above-y \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"Add attachment" \
|
||||
"$(read_ime_top)"
|
||||
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
|
||||
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
|
||||
# so the editing input renders again after every edit
|
||||
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
|
||||
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
|
||||
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
|
||||
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
|
||||
# row on the paseo-api35 layout.
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keycombination 113 122
|
||||
adb shell input keyevent 20 20 20 20 20 20 20 20
|
||||
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
|
||||
backspace_x="$((screen_width - 85))"
|
||||
backspace_y="$(($(read_ime_top) + 507))"
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-above-y \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"Add attachment" \
|
||||
"$(read_ime_top)"
|
||||
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
|
||||
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
|
||||
# so the editing input renders again after every edit
|
||||
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
|
||||
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
|
||||
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
|
||||
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
|
||||
# row on the paseo-api35 layout.
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keycombination 113 122
|
||||
adb shell input keyevent 20 20 20 20 20 20 20 20
|
||||
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
|
||||
backspace_x="$((screen_width - 85))"
|
||||
backspace_y="$(($(read_ime_top) + 507))"
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
|
||||
read -r changes_x changes_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
|
||||
)
|
||||
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
|
||||
ad wait 'id="changes-header"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
|
||||
ad press 'label="Close Explorer sidebar"' --settle
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
ad wait 'editable=true' 10000
|
||||
read -r changes_x changes_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
|
||||
)
|
||||
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
|
||||
ad wait 'id="changes-header"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
|
||||
ad press 'label="Close Explorer sidebar"' --settle
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
ad press 540 600 --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
ad press 540 600 --settle
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap 983 "$((attach_y - keyboard_shift))"
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait text "keyboard invariant line one" 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
|
||||
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-submit.json"
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap 983 "$((attach_y - keyboard_shift))"
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait text "keyboard invariant line one" 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
|
||||
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-submit.json"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "x" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
|
||||
read -r new_input_x new_input_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
|
||||
)
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
|
||||
# Typing more lines moves the setup fields up with the composer instead of
|
||||
# hiding their bottom rows behind it.
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input keyevent 66 66 66
|
||||
adb shell input text grown
|
||||
sleep 1
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-fields-follow-growth \
|
||||
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "x" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
|
||||
read -r new_input_x new_input_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
|
||||
)
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
|
||||
# Typing more lines moves the setup fields up with the composer instead of
|
||||
# hiding their bottom rows behind it.
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input keyevent 66 66 66
|
||||
adb shell input text grown
|
||||
sleep 1
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-fields-follow-growth \
|
||||
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
|
||||
read -r new_input_x new_input_y new_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
|
||||
)
|
||||
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
|
||||
# its old center can move into the setup fields after the height constraint.
|
||||
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
|
||||
read -r new_input_x new_input_y new_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
|
||||
)
|
||||
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
|
||||
# its old center can move into the setup fields after the height constraint.
|
||||
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
|
||||
# Dismissing the keyboard moves the composer down at its editing height and
|
||||
# brings the setup fields back fully visible above it.
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
node "${ASSERT}" xml-fields-above-composer \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
# Dismissing the keyboard moves the composer down at its editing height and
|
||||
# brings the setup fields back fully visible above it.
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
node "${ASSERT}" xml-fields-above-composer \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
}
|
||||
|
||||
# All hosts run the same scenario; only navigation differs.
|
||||
# For the shared scenario, tap the editor's blank right edge once. Tapping
|
||||
@@ -470,20 +482,80 @@ press_composer_control() {
|
||||
adb shell input tap "${x}" "${y}"
|
||||
}
|
||||
|
||||
# Opens suggestions with the keyboard closed, before it opens, or after it settled;
|
||||
# checks their bounds and taps the best match where it is drawn. Accessibility keeps
|
||||
# reporting rows behind the keyboard, so existence is not evidence. It also drops the
|
||||
# composer while suggestions are open, so the composer bounds come from a reference
|
||||
# snapshot with an empty input in the keyboard state the suggestions are checked in.
|
||||
check_autocomplete() {
|
||||
local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6"
|
||||
local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}"
|
||||
if [[ "${order}" == keyboard-closed ]]; then
|
||||
prepare_composer closed
|
||||
snapshot_json "${prefix}-reference.json"
|
||||
adb shell input text "${query}"
|
||||
else
|
||||
prepare_composer open
|
||||
# The composer follows the keyboard animation, which ends after the IME frame settles.
|
||||
sleep 1
|
||||
snapshot_json "${prefix}-reference.json"
|
||||
if [[ "${order}" == before-keyboard ]]; then
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
adb shell input text "${query}"
|
||||
open_editor_keyboard "${overlay_x}" "${overlay_y}"
|
||||
else
|
||||
adb shell input text "${query}"
|
||||
fi
|
||||
fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
# The popover remeasures once keyboard motion settles.
|
||||
sleep 1
|
||||
capture_screen "${prefix}.png"
|
||||
snapshot_json "${prefix}.json"
|
||||
local ime_top=""
|
||||
if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi
|
||||
local best_x best_y
|
||||
read -r best_x best_y < <(
|
||||
node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}"
|
||||
)
|
||||
adb shell input tap "${best_x}" "${best_y}"
|
||||
sleep 1
|
||||
snapshot_json "${prefix}-selected.json"
|
||||
node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}"
|
||||
clear_focused_input_with_adb
|
||||
echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted"
|
||||
}
|
||||
|
||||
# Drafts list commands for their selected provider, and a fresh install selects none.
|
||||
select_claude_model() {
|
||||
local x y h
|
||||
press_composer_control combined-model-selector
|
||||
ad wait 'id="agent-controls-model"' 10000
|
||||
ad press 'id="agent-controls-model"' --settle
|
||||
ad press 'id="model-provider-claude"' --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/model-list.json"
|
||||
read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*')
|
||||
adb shell input tap "${x}" "${y}"
|
||||
ad wait 'id="agent-controls-thinking"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
}
|
||||
|
||||
run_host_overlays() {
|
||||
local host="$1"
|
||||
if [[ "${host}" != chat ]]; then
|
||||
prepare_composer closed
|
||||
select_claude_model
|
||||
fi
|
||||
# Chat lists the mock provider's command; drafts list the release skills in
|
||||
# this repository's .claude/skills for the selected Claude model.
|
||||
local command_query=/release command_match=/release-beta command_inserted="/release-beta "
|
||||
if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi
|
||||
for order in keyboard-closed before-keyboard after-keyboard; do
|
||||
check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}"
|
||||
check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"'
|
||||
done
|
||||
for mode in closed open; do
|
||||
for query in / @README; do
|
||||
prepare_composer closed
|
||||
adb shell input text "${query}"
|
||||
if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
local kind=commands
|
||||
if [[ "${query}" == @* ]]; then kind=files; fi
|
||||
capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png"
|
||||
clear_focused_input_with_adb
|
||||
echo "PASS host=${host}: ${kind} popover keyboard=${mode}"
|
||||
done
|
||||
prepare_composer "${mode}"
|
||||
press_composer_control combined-model-selector
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
@@ -656,19 +728,29 @@ run_host_scenario() {
|
||||
run_host_scroll "${host}"
|
||||
}
|
||||
|
||||
run_host() {
|
||||
if [[ "${SCENARIO}" == overlays ]]; then
|
||||
run_host_overlays "$1"
|
||||
else
|
||||
run_host_scenario "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi
|
||||
adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
run_host_scenario chat
|
||||
run_host chat
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="workspace-tab-switcher-trigger"' --settle
|
||||
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
|
||||
ad press 'id="workspace-tab-switcher-new-agent"' --settle
|
||||
ad wait 'text="New Agent"' 10000
|
||||
run_host_scenario workspace-draft
|
||||
# Only a draft offers to import a session.
|
||||
ad wait 'id="composer-import-agent-pill"' 10000
|
||||
run_host workspace-draft
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
run_host_scenario new-workspace
|
||||
run_host new-workspace
|
||||
|
||||
echo "Composer keyboard invariants passed"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import sharp from "sharp";
|
||||
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
@@ -115,11 +116,14 @@ if (command === "stream-moved") {
|
||||
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
|
||||
);
|
||||
} else if (command === "rect") {
|
||||
// A trailing `*` matches the first identifier with that prefix.
|
||||
const [snapshotPath, identifier, edge] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null;
|
||||
const node = snapshot.data.nodes.find(
|
||||
(candidate) =>
|
||||
candidate.identifier === identifier ||
|
||||
(prefix !== null && candidate.identifier?.startsWith(prefix)) ||
|
||||
candidate.label === identifier ||
|
||||
(identifier === "editable" && candidate.type.endsWith("EditText")),
|
||||
);
|
||||
@@ -232,6 +236,27 @@ if (command === "stream-moved") {
|
||||
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
|
||||
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
|
||||
}
|
||||
} else if (command === "autocomplete-best") {
|
||||
// Prints the center of the expected best match after checking its placement
|
||||
// against the composer in the reference snapshot.
|
||||
const [snapshotPath, referencePath, expected, imeTopArgument] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const reference = JSON.parse(await fs.readFile(referencePath, "utf8"));
|
||||
const best = resolveAutocompletePlacement(snapshot, {
|
||||
expected,
|
||||
keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined,
|
||||
reference,
|
||||
});
|
||||
process.stdout.write(`${best.x} ${best.y}\n`);
|
||||
} else if (command === "composer-text") {
|
||||
// Accessibility drops a trailing space, so compare without it.
|
||||
const [snapshotPath, expected] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
|
||||
const text = input?.value ?? input?.label ?? "";
|
||||
if (text.trimEnd() !== expected.trimEnd()) {
|
||||
throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`);
|
||||
}
|
||||
} else if (command === "same-region") {
|
||||
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
|
||||
const crop = {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device
|
||||
// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that
|
||||
// sit behind the keyboard, so existence is not evidence: compare their screen bounds.
|
||||
|
||||
const bottomOf = (rect) => rect.y + rect.height;
|
||||
|
||||
function findById(nodes, identifier) {
|
||||
const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect);
|
||||
if (!node) throw new Error(`Missing node: ${identifier}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
function contains(outer, inner) {
|
||||
return (
|
||||
inner.x >= outer.x - 1 &&
|
||||
inner.y >= outer.y - 1 &&
|
||||
inner.x + inner.width <= outer.x + outer.width + 1 &&
|
||||
bottomOf(inner) <= bottomOf(outer) + 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The best match is the list row nearest the composer. Check that the popover sits
|
||||
* between the app's header controls and the composer, that the keyboard (when shown)
|
||||
* is below both, and that the row nearest the composer is fully drawn and is
|
||||
* `expected`. A clipped best match fails: the nearest whole row is then another one.
|
||||
* Return where to tap it. Android leaves the composer out of the accessibility tree
|
||||
* while suggestions are open, so its callers read the composer from `reference`: a
|
||||
* snapshot in the same keyboard state with an empty input.
|
||||
*/
|
||||
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
|
||||
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
|
||||
const nodes = snapshot.data.nodes;
|
||||
const popover = findById(nodes, "composer-autocomplete-popover").rect;
|
||||
const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect;
|
||||
// The popover may cover the compact header, never the status bar above it.
|
||||
const minimumTop =
|
||||
nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0;
|
||||
// iOS reports the keyboard in the app's tree; Android callers pass the IME frame.
|
||||
const keyboardY =
|
||||
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
|
||||
const popoverBottom = bottomOf(popover);
|
||||
if (popoverBottom > composer.y + 1) {
|
||||
throw new Error(
|
||||
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
|
||||
);
|
||||
}
|
||||
if (popover.y < minimumTop - 1) {
|
||||
throw new Error(
|
||||
`Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`,
|
||||
);
|
||||
}
|
||||
if (keyboardY !== undefined && popoverBottom > keyboardY + 1) {
|
||||
throw new Error(
|
||||
`Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`,
|
||||
);
|
||||
}
|
||||
// Rows are the innermost labelled boxes spanning the popover. The list scrolls to
|
||||
// its end, so the lowest row is the best match nearest the composer.
|
||||
const candidates = nodes.filter(
|
||||
(node) =>
|
||||
node.rect &&
|
||||
node.label &&
|
||||
node.identifier !== "composer-autocomplete-popover" &&
|
||||
!/scroll bar/i.test(node.label) &&
|
||||
node.rect.height > 0 &&
|
||||
// The popover border insets rows by a few pixels; their labels are narrower.
|
||||
node.rect.width >= popover.width * 0.97 &&
|
||||
contains(popover, node.rect),
|
||||
);
|
||||
const area = (rect) => rect.width * rect.height;
|
||||
const rows = candidates.filter(
|
||||
(outer) =>
|
||||
!candidates.some(
|
||||
(inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect),
|
||||
),
|
||||
);
|
||||
if (rows.length === 0) throw new Error("Autocomplete has no visible rows");
|
||||
const best = rows.reduce((lowest, row) =>
|
||||
bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest,
|
||||
);
|
||||
const label = best.label.split(", ")[0];
|
||||
if (label !== expected) {
|
||||
throw new Error(`Best visible match is ${label}, expected ${expected}`);
|
||||
}
|
||||
return {
|
||||
label,
|
||||
x: Math.round(best.rect.x + best.rect.width / 2),
|
||||
y: Math.round(best.rect.y + best.rect.height / 2),
|
||||
popover,
|
||||
composer,
|
||||
keyboardTop: keyboardY ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Composer autocomplete placement on iOS with the software keyboard: chat, workspace
|
||||
// draft tab, and New workspace. Run where agent-device reaches the simulator, with the
|
||||
// development app connected to the fixture host. See docs/mobile-testing.md.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
|
||||
const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback;
|
||||
const serverId = env("SERVER_ID");
|
||||
const agentId = env("AGENT_ID");
|
||||
const workspaceId = env("WORKSPACE_ID");
|
||||
if (!serverId || !agentId || !workspaceId) {
|
||||
throw new Error(
|
||||
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
|
||||
);
|
||||
}
|
||||
const draftId = env("DRAFT_ID", "composer-keyboard");
|
||||
const session = env("SESSION", "composer-keyboard-ios");
|
||||
const device = env("DEVICE", "iPhone 16 Pro");
|
||||
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
|
||||
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
|
||||
// Partial queries with known best matches. `inserted` is the composer text after
|
||||
// tapping the best match.
|
||||
const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' };
|
||||
// The release skills ship in this repository's .claude/skills.
|
||||
const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " };
|
||||
// The chat fixture's mock provider lists one command.
|
||||
const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
|
||||
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(name) {
|
||||
const output = ad("snapshot", "--raw", "--json");
|
||||
fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output);
|
||||
ad("screenshot", path.join(artifactsDir, `${name}.png`));
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect);
|
||||
const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard");
|
||||
const composerText = (snap) =>
|
||||
snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? "";
|
||||
|
||||
async function waitFor(name, predicate, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const snap = snapshot(name);
|
||||
if (predicate(snap)) return snap;
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`);
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position.
|
||||
async function settledPlacement(name, keyboard) {
|
||||
let previousTop;
|
||||
return waitFor(name, (snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
});
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
const placement = resolveAutocompletePlacement(snap, { expected });
|
||||
process.stdout.write(
|
||||
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
|
||||
placement.popover.y + placement.popover.height,
|
||||
)} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`,
|
||||
);
|
||||
return placement;
|
||||
}
|
||||
|
||||
// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a
|
||||
// draft has no stream, so open and close the model sheet, which takes input focus.
|
||||
async function dismissKeyboard(snap) {
|
||||
const composer = nodeById(snap, "message-input-root").rect;
|
||||
ad("press", "4", String(Math.round(composer.y - 8)));
|
||||
await sleep(1_000);
|
||||
if (!keyboardShown(snapshot("dismiss-keyboard"))) return;
|
||||
const sheetOpen = (sheet) =>
|
||||
sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close");
|
||||
ad("press", 'id="combined-model-selector"');
|
||||
await waitFor("dismiss-keyboard-sheet", sheetOpen);
|
||||
ad("press", 'label="Close"');
|
||||
// The input is not hittable until the sheet has gone.
|
||||
await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done));
|
||||
}
|
||||
|
||||
// Tap past the end of the (short) last line so the caret lands after the mention.
|
||||
// The input's right padding holds the fullscreen toggle; stay clear of it.
|
||||
function focusAtEnd(snap) {
|
||||
const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect;
|
||||
if (!input) throw new Error("Missing composer input");
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(input.x + input.width * 0.7)),
|
||||
String(Math.round(input.y + input.height - 6)),
|
||||
);
|
||||
}
|
||||
|
||||
// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end.
|
||||
async function clearInput(name) {
|
||||
focusAtEnd(snapshot(`${name}-before-clear`));
|
||||
const keyboard = await waitFor(`${name}-clear`, keyboardShown);
|
||||
const deleteKey = keyboard.data.nodes.find(
|
||||
(node) => node.type === "Key" && node.label === "delete" && node.rect,
|
||||
)?.rect;
|
||||
if (!deleteKey) throw new Error("Missing keyboard delete key");
|
||||
const length = composerText(keyboard).length;
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(deleteKey.x + deleteKey.width / 2)),
|
||||
String(Math.round(deleteKey.y + deleteKey.height / 2)),
|
||||
"--count",
|
||||
String(length + 2),
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
}
|
||||
|
||||
// The accessibility value drops a trailing space.
|
||||
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
|
||||
|
||||
async function runCase(host, kind, { text, expected, inserted }) {
|
||||
const name = `${host}-${kind}`;
|
||||
await clearInput(name);
|
||||
await dismissKeyboard(snapshot(`${name}-empty`));
|
||||
// fill taps the input and types at once, so suggestions open while the keyboard opens.
|
||||
ad("fill", "editable=true", text);
|
||||
assertPlacement(
|
||||
`${name} opened while keyboard opens`,
|
||||
await settledPlacement(`${name}-opening`, true),
|
||||
expected,
|
||||
);
|
||||
await clearInput(`${name}-settled`);
|
||||
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
|
||||
ad("type", text);
|
||||
assertPlacement(
|
||||
`${name} opened after keyboard settled`,
|
||||
await settledPlacement(`${name}-open`, true),
|
||||
expected,
|
||||
);
|
||||
await dismissKeyboard(snapshot(`${name}-before-close`));
|
||||
assertPlacement(
|
||||
`${name} keyboard closed`,
|
||||
await settledPlacement(`${name}-closed`, false),
|
||||
expected,
|
||||
);
|
||||
focusAtEnd(snapshot(`${name}-before-reopen`));
|
||||
const reopened = assertPlacement(
|
||||
`${name} keyboard opened under open suggestions`,
|
||||
await settledPlacement(`${name}-reopened`, true),
|
||||
expected,
|
||||
);
|
||||
ad("press", String(reopened.x), String(reopened.y));
|
||||
const selected = await waitFor(`${name}-selected`, (snap) =>
|
||||
sameText(composerText(snap), inserted),
|
||||
);
|
||||
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
|
||||
process.stdout.write(
|
||||
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Drafts list commands for their selected provider, and a fresh install selects none.
|
||||
async function selectClaudeModel(host) {
|
||||
ad("press", 'id="combined-model-selector"');
|
||||
await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model")));
|
||||
ad("press", 'id="agent-controls-model"');
|
||||
await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude")));
|
||||
ad("press", 'id="model-provider-claude"');
|
||||
const models = await waitFor(`${host}-models`, (snap) =>
|
||||
snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect),
|
||||
);
|
||||
const row = models.data.nodes.find(
|
||||
(node) => node.identifier?.startsWith("model-row-claude-") && node.rect,
|
||||
).rect;
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(row.x + row.width / 2)),
|
||||
String(Math.round(row.y + row.height / 2)),
|
||||
);
|
||||
await waitFor(`${host}-model-selected`, (snap) =>
|
||||
Boolean(nodeById(snap, "agent-controls-thinking")),
|
||||
);
|
||||
ad("press", 'label="Close"');
|
||||
await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model"));
|
||||
}
|
||||
|
||||
async function runHost(host) {
|
||||
if (host !== "chat") await selectClaudeModel(host);
|
||||
await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase);
|
||||
await runCase(host, "files", fileCase);
|
||||
await runCase(host, "files-grown", {
|
||||
text: `${grownPrefix}${fileCase.text}`,
|
||||
expected: fileCase.expected,
|
||||
inserted: `${grownPrefix}${fileCase.inserted}`,
|
||||
});
|
||||
await clearInput(`${host}-done`);
|
||||
await dismissKeyboard(snapshot(`${host}-done`));
|
||||
}
|
||||
|
||||
const hosts = [
|
||||
["chat", `paseo://h/${serverId}/agent/${agentId}`],
|
||||
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
|
||||
["new-workspace", `paseo://new?serverId=${serverId}`],
|
||||
];
|
||||
for (const [host, url] of hosts) {
|
||||
ad("open", url, "--platform", "ios", "--device", device);
|
||||
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
|
||||
await runHost(host);
|
||||
}
|
||||
process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`);
|
||||
@@ -35,7 +35,8 @@ export const test = base.extend<{
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.session_open", async ({ request }) => {
|
||||
if (request.reason !== "create") return request;
|
||||
// Metadata helpers have no workspace and must not consume the one-shot failure.
|
||||
if (request.reason !== "create" || !request.workspaceId) return request;
|
||||
const gate = ${JSON.stringify(gate)};
|
||||
let command = await readFile(gate, "utf8").catch(() => "release");
|
||||
while (command === "hold") {
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function openCommandCenterForAgent(
|
||||
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
|
||||
): Promise<Locator> {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const panel = await openCommandCenter(page);
|
||||
return panel.getByTestId("command-center-input");
|
||||
}
|
||||
|
||||
@@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult(
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
expect(overflow.whiteSpace).toBe("nowrap");
|
||||
}
|
||||
|
||||
/** Hold real daemon responses until the user has finished the interaction under test. */
|
||||
export async function holdDirectorySuggestionResponses(page: Page) {
|
||||
let holding = false;
|
||||
const pending: Array<() => void> = [];
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") {
|
||||
pending.push(() => ws.send(message));
|
||||
} else {
|
||||
ws.send(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
hold() {
|
||||
holding = true;
|
||||
},
|
||||
async release() {
|
||||
await expect.poll(() => pending.length).toBeGreaterThan(0);
|
||||
holding = false;
|
||||
for (const send of pending.splice(0)) send();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { createMockIdleAgent } from "./archive-tab";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
export async function seedSearchableCommandCenter() {
|
||||
const seed = await seedWorkspace({
|
||||
repoPrefix: "command-center-performance-",
|
||||
title: "Search workspace",
|
||||
git: false,
|
||||
});
|
||||
try {
|
||||
const agents = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
agents.push(
|
||||
await createMockIdleAgent(seed.client, {
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
title: `Search agent ${String(index).padStart(3, "0")}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const workspaces = [];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const path = `${seed.repoPath}/workspace-${index}`;
|
||||
mkdirSync(path);
|
||||
const result = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path, projectId: seed.projectId },
|
||||
title: `Search workspace ${String(index).padStart(3, "0")}`,
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed");
|
||||
workspaces.push(result.workspace);
|
||||
}
|
||||
return { ...seed, agents, workspaces };
|
||||
} catch (error) {
|
||||
await seed.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeCommandCenterRowRenders(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchCommandCenter(panel: Locator, query: string) {
|
||||
await panel.getByTestId("command-center-input").fill(query);
|
||||
}
|
||||
|
||||
export async function warmCommandCenterRows(page: Page) {
|
||||
// FlatList fills its overscan window in timed batches after the first visible frame.
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
const renders = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) =>
|
||||
id.startsWith("CommandCenterResultRow:"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
|
||||
}
|
||||
|
||||
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
|
||||
const rows = panel.locator(
|
||||
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
|
||||
);
|
||||
const count = await rows.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(total);
|
||||
}
|
||||
|
||||
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
|
||||
await panel.getByTestId("command-center-results").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
}
|
||||
@@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing(
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
|
||||
}
|
||||
|
||||
/** Wait for the browser to deliver wheel events before publishing a daemon update. */
|
||||
export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) {
|
||||
await page.setViewportSize({ width: 1280, height: 500 });
|
||||
const viewport = panel.getByTestId("command-center-results");
|
||||
await viewport.hover();
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
|
||||
const samples = await sampleCommandCenterScroll(panel);
|
||||
const offset = samples.at(-1)!;
|
||||
expect(offset).toBeGreaterThan(100);
|
||||
return offset;
|
||||
}
|
||||
|
||||
async function sampleCommandCenterScroll(panel: Locator): Promise<number[]> {
|
||||
return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
samples.push(viewport.scrollTop);
|
||||
}
|
||||
return samples;
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) {
|
||||
const samples = await sampleCommandCenterScroll(panel);
|
||||
expect(Math.min(...samples)).toBe(offset);
|
||||
expect(Math.max(...samples)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect
|
||||
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
|
||||
.toBeLessThan(offset);
|
||||
const second = panel.getByTestId("command-center-results").getByRole("button").nth(1);
|
||||
await expect(second).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
workspaceDeckEntryLocator,
|
||||
} from "./workspace-ui";
|
||||
import { getServerId } from "./server-id";
|
||||
import { revealSidebarProjectWorkspaces } from "./sidebar";
|
||||
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
|
||||
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
|
||||
@@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) {
|
||||
});
|
||||
}
|
||||
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
|
||||
await revealSidebarProjectWorkspaces(page, project.projectKey);
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
|
||||
await page
|
||||
.getByTestId(draftTestId!)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function isFileUploadResponse(message: WebSocketMessage): boolean {
|
||||
if (typeof message !== "string") return false;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } };
|
||||
return (envelope.message ?? envelope).type === "file.upload.response";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the daemon's first `file.upload.response`, so the upload is stored on
|
||||
* the host but the app still sees it in flight.
|
||||
*/
|
||||
export async function holdFileUploadResponse(page: Page) {
|
||||
let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null;
|
||||
let resolveHeld: (() => void) | null = null;
|
||||
const heldResponse = new Promise<void>((resolve) => {
|
||||
resolveHeld = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (held || !isFileUploadResponse(message)) {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
held = { browser, message };
|
||||
resolveHeld?.();
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitUntilHeld: () => heldResponse,
|
||||
release: () => held?.browser.send(held.message),
|
||||
/** Drops the app's connection while the upload is in flight. */
|
||||
disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// Poses as the desktop app running the e2e daemon as its own managed daemon, so
|
||||
// `useIsLocalDaemon` reports the host as this machine. `directory` is where the
|
||||
// desktop bridge claims dropped files live on disk.
|
||||
export async function installLocalDesktopHost(page: Page, directory: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set");
|
||||
}
|
||||
await page.addInitScript(
|
||||
(localHost: { serverId: string; directory: string }) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "linux",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localHost.serverId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` },
|
||||
};
|
||||
},
|
||||
{ serverId, directory },
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export interface SeedDaemonClient {
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
||||
setWorkspaceLabel(input: {
|
||||
@@ -147,6 +148,7 @@ export interface SeedDaemonClient {
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getServerId } from "./server-id";
|
||||
import { projectEquivalenceViewKey } from "./project-view-key";
|
||||
|
||||
interface ContextMenuAnchor {
|
||||
x: number;
|
||||
@@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise<void> {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealSidebarProjectWorkspaces(
|
||||
page: Page,
|
||||
projectKey: string,
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
export async function expectCompactSidebarOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"image": "macos-tahoe-26.6-xcode-27.1"
|
||||
},
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "npm run start:expo",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps",
|
||||
"android": "npm run dev:android",
|
||||
"android:development": "npm run dev:android",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -35,7 +35,6 @@
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
|
||||
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.js"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
build.onResolve({ filter: /^@server\// }, async (args) => ({
|
||||
path: await resolveTsPath(
|
||||
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
loader: {
|
||||
".css": "text",
|
||||
},
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const js = result.outputFiles[0]?.text;
|
||||
if (!js) {
|
||||
throw new Error("terminal webview bundle produced no JavaScript");
|
||||
}
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function resolveAgentLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
const agent =
|
||||
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const icon = useProviderIcon(descriptorState.provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, icon });
|
||||
}
|
||||
|
||||
export function buildAgentDescriptor(input: {
|
||||
provider: Agent["provider"];
|
||||
title: string | null;
|
||||
status: Agent["status"] | null;
|
||||
pendingPermissionCount: number;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: Agent["attentionReason"];
|
||||
isTurnActive: boolean;
|
||||
icon: PanelDescriptor["icon"];
|
||||
}): PanelDescriptor {
|
||||
const provider = input.provider;
|
||||
const label = resolveAgentLabel(input.title);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: input.icon,
|
||||
statusBucket: input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
@@ -507,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
{surface}
|
||||
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
useAssistantFileLinkEnvironment,
|
||||
useAssistantFileLinkEnvironmentBridge,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
|
||||
@@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue {
|
||||
const AssistantFileLinkResolverContext =
|
||||
createContext<AssistantFileLinkResolverContextValue | null>(null);
|
||||
|
||||
export function useAssistantFileLinkEnvironmentBridge() {
|
||||
const value = useContext(AssistantFileLinkResolverContext);
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<AssistantFileLinkResolverContext.Provider value={value}>
|
||||
{children}
|
||||
</AssistantFileLinkResolverContext.Provider>
|
||||
),
|
||||
[value],
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantFileLinkResolverProvider({
|
||||
client,
|
||||
serverId,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import { AssistantFileLinkResolverProvider } from "./provider";
|
||||
@@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t
|
||||
}
|
||||
|
||||
describe("useFileLink", () => {
|
||||
// Lookups resolve after a test's last assertion; an unmounted hook cannot re-render
|
||||
// once jsdom is torn down.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("returns the same object across no-op parent rerenders", () => {
|
||||
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Largest file the app sends through `client.uploadFile`. */
|
||||
export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
@@ -1,3 +1,5 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -10,12 +12,25 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { useWorkspaceStatus } from "@/workspace-status";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -69,9 +84,7 @@ import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
moveActiveResultId,
|
||||
PINNED_SECTION_BAND,
|
||||
preserveActiveResultId,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterFileResult,
|
||||
@@ -81,6 +94,7 @@ import {
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
|
||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
@@ -106,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId, workspaceId });
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentResultAction(serverId: string, agentId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId, agentId });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
|
||||
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
|
||||
@@ -115,6 +143,9 @@ function useBuiltInRows(open: boolean): {
|
||||
agents: CommandCenterAgentResult[];
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const previousRows = useRef(
|
||||
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
|
||||
);
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
@@ -136,7 +167,22 @@ function useBuiltInRows(open: boolean): {
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return { workspaces: [], agents: [] };
|
||||
if (!open) {
|
||||
previousRows.current.clear();
|
||||
return { workspaces: [], agents: [] };
|
||||
}
|
||||
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
|
||||
// row data so a directory update does not invalidate every mounted ResultRow.
|
||||
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
|
||||
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
|
||||
row: T,
|
||||
): T => {
|
||||
const previous = previousRows.current.get(row.id);
|
||||
if (previous) row.run = previous.run;
|
||||
const stable = previous && equal(previous, row) ? (previous as T) : row;
|
||||
nextRows.set(row.id, stable);
|
||||
return stable;
|
||||
};
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
@@ -147,23 +193,24 @@ function useBuiltInRows(open: boolean): {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
allWorkspaces.push({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
},
|
||||
});
|
||||
allWorkspaces.push(
|
||||
preserve({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
serverId: host.serverId,
|
||||
workspaceId: workspace.id,
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: createWorkspaceResultAction(host.serverId, workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,22 +221,20 @@ function useBuiltInRows(open: boolean): {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
return preserve({
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
]),
|
||||
activityAt: agent.lastActivityAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
run: createAgentResultAction(agent.serverId, agent.id),
|
||||
});
|
||||
});
|
||||
previousRows.current = nextRows;
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
}
|
||||
@@ -227,8 +272,6 @@ interface CommandCenterState {
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
rows: readonly CommandCenterListRow[];
|
||||
results: readonly CommandCenterResult[];
|
||||
rowIndexByResultId: ReadonlyMap<string, number>;
|
||||
offsets: readonly number[];
|
||||
inputRef: React.RefObject<EditingTextInputHandle | null>;
|
||||
fileSearchLoading: boolean;
|
||||
@@ -238,7 +281,10 @@ interface CommandCenterState {
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
|
||||
function useCommandCenterState(
|
||||
listMetrics: CommandCenterListMetrics,
|
||||
interaction: CommandCenterInteraction,
|
||||
): CommandCenterState {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
@@ -248,8 +294,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const [query, setQueryState] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
const {
|
||||
entries: fileSearchEntries,
|
||||
@@ -296,15 +341,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
|
||||
// query is no longer meaningful. Reset it in the same update as the query rather than in an
|
||||
// effect, so there is no intermediate render holding the stale selection.
|
||||
const setQuery = useCallback((next: string) => {
|
||||
setQueryState(next);
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
|
||||
}, [interaction, open, scope, query, projection, fileSearchLoading]);
|
||||
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
const select = useCallback(
|
||||
@@ -327,18 +366,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
const selected = results.find((result) => result.id === resolvedActiveId);
|
||||
const selected = results.find((result) => result.id === activeId);
|
||||
if (!selected) return false;
|
||||
select(selected);
|
||||
return true;
|
||||
}
|
||||
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
|
||||
if (results.length === 0) return false;
|
||||
const direction = pressed === "ArrowDown" ? "next" : "previous";
|
||||
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
|
||||
return true;
|
||||
return interaction.move(pressed === "ArrowDown" ? "next" : "previous");
|
||||
},
|
||||
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
|
||||
[
|
||||
close,
|
||||
open,
|
||||
projection.selectableResults,
|
||||
query,
|
||||
activeId,
|
||||
scope,
|
||||
select,
|
||||
setScope,
|
||||
interaction,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -348,8 +394,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setQueryState("");
|
||||
setActiveId(null);
|
||||
if (!wasOpen) return;
|
||||
const element = takeCommandCenterFocusRestoreElement();
|
||||
if (!element) return;
|
||||
@@ -367,11 +411,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
scope,
|
||||
clearScope: () => setScope(null),
|
||||
query,
|
||||
setQuery,
|
||||
activeId: resolvedActiveId,
|
||||
setQuery: interaction.setQuery,
|
||||
activeId,
|
||||
rows: projection.rows,
|
||||
results: projection.selectableResults,
|
||||
rowIndexByResultId: projection.rowIndexByResultId,
|
||||
offsets: projection.offsets,
|
||||
inputRef,
|
||||
fileSearchLoading,
|
||||
@@ -397,6 +439,7 @@ const ResultRow = memo(function ResultRow({
|
||||
active,
|
||||
onSelect,
|
||||
}: ResultRowProps) {
|
||||
recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]);
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const choice =
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
@@ -430,7 +473,19 @@ const ResultRow = memo(function ResultRow({
|
||||
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
|
||||
}
|
||||
>
|
||||
<ResultContent result={result} query={query} />
|
||||
{(state) => (
|
||||
<ResultContent
|
||||
result={result}
|
||||
query={query}
|
||||
backdrop={
|
||||
active ||
|
||||
state.pressed ||
|
||||
Boolean((state as PressableStateCallbackType & { hovered?: boolean }).hovered)
|
||||
? "surface1"
|
||||
: "surface0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -484,6 +539,72 @@ function SubtitleWithTimestamp({
|
||||
);
|
||||
}
|
||||
|
||||
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function AgentResultContent({
|
||||
result,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterAgentResult;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const agent = result.agent;
|
||||
const descriptor = useAgentDescriptor(
|
||||
{ kind: "agent", agentId: agent.id },
|
||||
{ serverId: agent.serverId },
|
||||
);
|
||||
const Icon = useMemo(
|
||||
() => withUnistyles(descriptor.icon, providerIconMapping),
|
||||
[descriptor.icon],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
|
||||
<Icon size={16} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceResultIcon({
|
||||
result,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterWorkspaceResult;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
|
||||
return (
|
||||
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
@@ -498,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu
|
||||
);
|
||||
}
|
||||
|
||||
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
|
||||
function ResultContent({
|
||||
result,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterResult;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
if (result.kind === "file") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
@@ -525,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
);
|
||||
}
|
||||
if (result.kind === "agent") {
|
||||
const agent = result.agent;
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
showInactive
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
|
||||
}
|
||||
if (result.kind === "workspace") {
|
||||
const key = result.id.slice("workspace:".length);
|
||||
@@ -560,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
|
||||
<View
|
||||
style={styles.iconSlot}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
aria-hidden
|
||||
>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
<View style={styles.iconSlot}>
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
@@ -669,62 +766,39 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
|
||||
const [interaction] = useState(() =>
|
||||
createCommandCenterInteraction((offset) => {
|
||||
const list = listRef.current ?? bottomSheetListRef.current;
|
||||
list?.scrollToOffset({ offset, animated: false });
|
||||
}),
|
||||
);
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
interaction,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: state.close,
|
||||
});
|
||||
|
||||
const revealActiveResult = useCallback(() => {
|
||||
if (!state.open || !state.activeId) return;
|
||||
const index = state.rowIndexByResultId.get(state.activeId);
|
||||
if (index === undefined) return;
|
||||
const { offset, visibleLength } = scrollMetricsRef.current;
|
||||
if (visibleLength <= 0) return;
|
||||
const rowTop = state.offsets[index];
|
||||
const rowBottom = rowTop + state.rows[index].height;
|
||||
let nextOffset: number | null = null;
|
||||
if (rowTop < offset) nextOffset = rowTop;
|
||||
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
|
||||
if (nextOffset === null) return;
|
||||
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
|
||||
const boundedOffset = Math.max(0, nextOffset);
|
||||
scrollMetricsRef.current.offset = boundedOffset;
|
||||
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
|
||||
}, [
|
||||
showBottomSheet,
|
||||
state.activeId,
|
||||
state.offsets,
|
||||
state.open,
|
||||
state.rowIndexByResultId,
|
||||
state.rows,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
|
||||
return;
|
||||
}
|
||||
revealActiveResult();
|
||||
}, [revealActiveResult, state.open]);
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
|
||||
revealActiveResult();
|
||||
interaction.layout(event.nativeEvent.layout.height);
|
||||
},
|
||||
[revealActiveResult],
|
||||
[interaction],
|
||||
);
|
||||
const handleListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
interaction.scrolled(event.nativeEvent.contentOffset.y);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showBottomSheet || !state.open) return;
|
||||
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
@@ -790,6 +864,14 @@ export function CommandCenter() {
|
||||
windowSize: 5,
|
||||
onLayout: handleListLayout,
|
||||
onScroll: handleListScroll,
|
||||
onScrollBeginDrag: interaction.beginManualScroll,
|
||||
onTouchStart: interaction.beginManualScroll,
|
||||
...(isWeb
|
||||
? {
|
||||
onWheel: interaction.beginManualScroll,
|
||||
onMouseDown: interaction.beginManualScroll,
|
||||
}
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCommandCenterInteraction } from "./interaction";
|
||||
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
|
||||
function results(ids = ["a", "b", "c", "d"]) {
|
||||
return projectCommandCenterRows(
|
||||
[
|
||||
{
|
||||
id: "files",
|
||||
band: 0,
|
||||
rank: 0,
|
||||
title: "Files",
|
||||
results: ids.map<CommandCenterFileResult>((id) => ({
|
||||
id,
|
||||
kind: "file",
|
||||
filePath: id,
|
||||
title: id,
|
||||
subtitle: "",
|
||||
run() {},
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
|
||||
);
|
||||
}
|
||||
|
||||
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
|
||||
const offsets: number[] = [];
|
||||
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
|
||||
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
|
||||
model.update({
|
||||
open: true,
|
||||
scope: options.scope ?? null,
|
||||
query: model.getSnapshot().query,
|
||||
projection: results(ids),
|
||||
loading,
|
||||
});
|
||||
publish();
|
||||
if (options.measured !== false) model.layout(80);
|
||||
return { model, offsets, publish };
|
||||
}
|
||||
|
||||
describe("Command Center interaction", () => {
|
||||
it("keeps manual scrolling through result refresh, removal, and resizing", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
publish();
|
||||
publish(["b", "c", "d"]);
|
||||
model.layout(60);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("reveals only clipped keyboard selections and wraps around", () => {
|
||||
const { model, offsets } = setup();
|
||||
expect(model.move("next")).toBe(true);
|
||||
expect(offsets).toEqual([20]);
|
||||
model.scrolled(20); // The programmatic scroll callback is only a measurement.
|
||||
model.move("next");
|
||||
expect(offsets).toEqual([20, 60]);
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([20, 60]);
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([20, 60, 20]);
|
||||
model.move("previous");
|
||||
expect(model.getSnapshot().activeId).toBe("d");
|
||||
expect(offsets).toEqual([20, 60, 20, 100]);
|
||||
});
|
||||
|
||||
it("keyboard navigation takes control again after manual scrolling", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
model.move("next");
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([60]);
|
||||
});
|
||||
|
||||
it("a new query reveals even when the active ID stays the same", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.scrolled(100);
|
||||
model.setQuery("a");
|
||||
expect(offsets).toEqual([]);
|
||||
publish(["a", "b"]);
|
||||
expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" });
|
||||
expect(offsets).toEqual([20]);
|
||||
model.scrolled(100);
|
||||
publish(["a", "b"]);
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("an arrow command reveals a single result even when its ID cannot change", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
publish(["a"]);
|
||||
model.scrolled(30);
|
||||
model.move("next");
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("defers reveals until layout exists and consumes them only once", () => {
|
||||
const { model, offsets, publish } = setup({ measured: false });
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([]);
|
||||
model.layout(80);
|
||||
expect(offsets).toEqual([100]);
|
||||
model.scrolled(0);
|
||||
publish();
|
||||
model.layout(60);
|
||||
expect(offsets).toEqual([100]);
|
||||
});
|
||||
|
||||
it("manual interaction cancels an unmeasured request", () => {
|
||||
const { model, offsets } = setup({ measured: false });
|
||||
model.move("previous");
|
||||
model.beginManualScroll();
|
||||
model.layout(80);
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for scoped search results instead of revealing the previous query's rows", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
model.scrolled(100);
|
||||
model.setQuery("new");
|
||||
publish(["a", "b"], true);
|
||||
expect(offsets).toEqual([]);
|
||||
expect(model.getSnapshot().activeId).toBeNull();
|
||||
publish(["b", "a"]);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("manual interaction cancels a reveal awaiting search results", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
model.setQuery("new");
|
||||
publish([], true);
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
publish(["new"]);
|
||||
expect(model.getSnapshot().activeId).toBe("new");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("empty searches complete without granting future passive results permission to scroll", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.setQuery("missing");
|
||||
publish([]);
|
||||
expect(model.move("next")).toBe(false);
|
||||
expect(model.getSnapshot().activeId).toBeNull();
|
||||
model.scrolled(100);
|
||||
publish(["late"]);
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("scope changes reset selection and request a reveal", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.move("next");
|
||||
model.scrolled(100);
|
||||
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([20, 20]);
|
||||
});
|
||||
|
||||
it("close discards the old query, metrics, and deferred reveal", () => {
|
||||
const { model, offsets, publish } = setup({ measured: false });
|
||||
model.setQuery("old");
|
||||
publish();
|
||||
model.move("previous");
|
||||
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
|
||||
model.layout(80);
|
||||
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
|
||||
expect(offsets).toEqual([]);
|
||||
publish();
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
moveActiveResultId,
|
||||
preserveActiveResultId,
|
||||
type CommandCenterListProjection,
|
||||
} from "./results";
|
||||
|
||||
interface Snapshot {
|
||||
query: string;
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
interface ResultsInput {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
query: string;
|
||||
projection: CommandCenterListProjection;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Selection persists; permission to move the viewport lasts for one user command. */
|
||||
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
|
||||
let snapshot: Snapshot = { query: "", activeId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
let open = false;
|
||||
let scope: CommandCenterScope = null;
|
||||
let projection: CommandCenterListProjection | null = null;
|
||||
let loading = false;
|
||||
let revealPending = false;
|
||||
let offset = 0;
|
||||
let visibleLength = 0;
|
||||
|
||||
function publish(next: Snapshot) {
|
||||
if (next.query === snapshot.query && next.activeId === snapshot.activeId) return;
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
if (!open || !revealPending || !projection || visibleLength <= 0) return;
|
||||
// Scoped file search keeps its previous entries while the next query is loading.
|
||||
if (scope === "files" && loading) return;
|
||||
if (!snapshot.activeId) {
|
||||
if (!loading) revealPending = false;
|
||||
return;
|
||||
}
|
||||
const index = projection.rowIndexByResultId.get(snapshot.activeId);
|
||||
if (index === undefined) return;
|
||||
const top = projection.offsets[index];
|
||||
const bottom = top + projection.rows[index].height;
|
||||
revealPending = false;
|
||||
let nextOffset = offset;
|
||||
if (top < offset) nextOffset = top;
|
||||
if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength;
|
||||
nextOffset = Math.max(0, nextOffset);
|
||||
if (nextOffset === offset) return;
|
||||
offset = nextOffset;
|
||||
scrollToOffset(nextOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
update(input: ResultsInput) {
|
||||
const resetSelection = input.open && (!open || scope !== input.scope);
|
||||
open = input.open;
|
||||
scope = input.scope;
|
||||
if (!open) {
|
||||
projection = null;
|
||||
revealPending = false;
|
||||
offset = 0;
|
||||
visibleLength = 0;
|
||||
publish({ query: "", activeId: null });
|
||||
return;
|
||||
}
|
||||
if (resetSelection) {
|
||||
revealPending = true;
|
||||
publish({ ...snapshot, activeId: null });
|
||||
}
|
||||
// A query command invalidates the old projection until React supplies its new results.
|
||||
if (input.query !== snapshot.query) return;
|
||||
projection = input.projection;
|
||||
loading = input.loading;
|
||||
if (scope !== "files" || !loading) {
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
|
||||
});
|
||||
}
|
||||
reveal();
|
||||
},
|
||||
setQuery(query: string) {
|
||||
projection = null;
|
||||
revealPending = true;
|
||||
publish({ query, activeId: null });
|
||||
},
|
||||
move(direction: "next" | "previous"): boolean {
|
||||
if (
|
||||
!open ||
|
||||
!projection ||
|
||||
(scope === "files" && loading) ||
|
||||
projection.selectableResults.length === 0
|
||||
)
|
||||
return false;
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
|
||||
});
|
||||
revealPending = true;
|
||||
reveal();
|
||||
return true;
|
||||
},
|
||||
layout(height: number) {
|
||||
visibleLength = height;
|
||||
reveal();
|
||||
},
|
||||
scrolled(nextOffset: number) {
|
||||
offset = nextOffset;
|
||||
},
|
||||
beginManualScroll() {
|
||||
revealPending = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CommandCenterInteraction = ReturnType<typeof createCommandCenterInteraction>;
|
||||
@@ -110,6 +110,8 @@ function workspace(
|
||||
): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface CommandCenterWorkspaceResult {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
project: CommandCenterWorkspaceProject;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
@@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath: path,
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||
useWorkspaceLayoutStore.getState().openTab({
|
||||
workspaceKey,
|
||||
@@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
intent: "reveal",
|
||||
placement,
|
||||
}),
|
||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||
revealWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[serverId, workspaceId],
|
||||
|
||||
@@ -11,6 +11,8 @@ function workspace(input: {
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
id: input.id,
|
||||
title: input.title ?? input.id,
|
||||
subtitle: input.subtitle ?? "",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -474,6 +475,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const openNewWorkspaceForProject = useCallback(
|
||||
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
|
||||
onClose();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId,
|
||||
@@ -621,6 +623,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
testID: "add-project-flow-add-host",
|
||||
select: () => {
|
||||
onClose();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export function FileDropBackdrop() {
|
||||
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
|
||||
// on theme change — see docs/unistyles.md).
|
||||
return (
|
||||
<Animated.View style={overlayStyle} pointerEvents="none">
|
||||
<Animated.View style={overlayStyle} pointerEvents="none" testID="file-drop-backdrop">
|
||||
<View style={styles.backdrop} />
|
||||
<View style={styles.content}>
|
||||
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
|
||||
|
||||
@@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
|
||||
/**
|
||||
* What a consumer (e.g. a composer) registers to receive files dropped onto the
|
||||
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
|
||||
* everything else arrives raw via `onGenericFiles`.
|
||||
* surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that
|
||||
* also provides `onFiles` gets its raster images persisted as attachments.
|
||||
*/
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onFiles?: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||
}
|
||||
|
||||
@@ -148,8 +148,9 @@ export function useDropListeners({
|
||||
sink.onGenericFiles(items);
|
||||
}
|
||||
|
||||
const imagePaths = payload.paths.filter(isRasterImagePath);
|
||||
if (imagePaths.length === 0) {
|
||||
const onFiles = sink.onFiles;
|
||||
const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : [];
|
||||
if (!onFiles || imagePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,7 +163,7 @@ export function useDropListeners({
|
||||
// composer the user dropped on (matches the web path below). No post-persist busy
|
||||
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
|
||||
// would discard the image from the same drop.
|
||||
sink.onFiles(attachments);
|
||||
onFiles(attachments);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -262,16 +263,17 @@ export function useDropListeners({
|
||||
sink.onGenericFiles(genericItems);
|
||||
}
|
||||
|
||||
const imageFiles = files.filter(isRasterImageFile);
|
||||
const onFiles = sink.onFiles;
|
||||
const imageFiles = onFiles ? files.filter(isRasterImageFile) : [];
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
if (!onFiles || imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
|
||||
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
|
||||
// and re-checking would discard the image from the same drop. The guard at drop start
|
||||
// already rejects drops that begin while busy.
|
||||
sink.onFiles(attachments);
|
||||
onFiles(attachments);
|
||||
} catch (error) {
|
||||
console.error("[useDropListeners] Failed to process dropped files:", error);
|
||||
}
|
||||
|
||||
@@ -166,9 +166,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
void openProjectPicker();
|
||||
}, [showMobileAgent, openProjectPicker]);
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
@@ -205,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
}, []);
|
||||
|
||||
const handleImportSessionMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
openImportSession();
|
||||
}, [openImportSession, showMobileAgent]);
|
||||
}, [openImportSession]);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -83,6 +84,8 @@ function workspace(input: {
|
||||
scripts?: WorkspaceDescriptor["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
@@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
workspaceOrderByProject: {
|
||||
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
|
||||
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar group visibility", () => {
|
||||
const entries = (statuses: WorkspaceDescriptor["status"][]) =>
|
||||
statuses.map((status, index) =>
|
||||
createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: workspace({
|
||||
id: `row-${index + 1}`,
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: `Row ${index + 1}`,
|
||||
status,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const policy = {
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (entry: ReturnType<typeof createSidebarWorkspaceEntry>) => entry.statusBucket,
|
||||
};
|
||||
const names = (items: readonly ReturnType<typeof createSidebarWorkspaceEntry>[]) =>
|
||||
items.map((item) => item.workspaceId);
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
|
||||
it.each(["needs_input", "failed", "attention", "running"] as const)(
|
||||
"keeps row twelve visible for %s without filling gaps",
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
"row-2",
|
||||
"row-3",
|
||||
"row-4",
|
||||
"row-5",
|
||||
"row-6",
|
||||
"row-7",
|
||||
"row-12",
|
||||
]);
|
||||
expect(result.current.visibleItems[7]).toBe(items[11]);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("shows eight active rows once with no redundant toggle", () => {
|
||||
const items = entries([
|
||||
"running",
|
||||
"needs_input",
|
||||
"attention",
|
||||
"failed",
|
||||
"running",
|
||||
"attention",
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
|
||||
it("recomputes current ordered membership and retains reveal through status, order and size changes", () => {
|
||||
const items = entries([
|
||||
...Array(7).fill("done"),
|
||||
"running",
|
||||
"done",
|
||||
"failed",
|
||||
"done",
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup(rows, policy),
|
||||
{ initialProps: { rows: items } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
"row-2",
|
||||
"row-3",
|
||||
"row-4",
|
||||
"row-5",
|
||||
"row-6",
|
||||
"row-7",
|
||||
"row-8",
|
||||
"row-10",
|
||||
"row-12",
|
||||
]);
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
expect(result.current.expanded).toBe(true);
|
||||
rerender({ rows: items.slice(0, 3) });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
rerender({ rows: items.toReversed() });
|
||||
expect(result.current.visibleItems).toEqual(items.toReversed());
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-12",
|
||||
"row-11",
|
||||
"row-10",
|
||||
"row-9",
|
||||
"row-8",
|
||||
"row-7",
|
||||
"row-6",
|
||||
]);
|
||||
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
|
||||
rerender({ rows: [newest, ...items] });
|
||||
expect(result.current.visibleItems[0]).toBe(newest);
|
||||
expect(result.current.visibleItems).toHaveLength(10);
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
|
||||
const items = entries(Array(12).fill("done"));
|
||||
const descriptor = workspace({
|
||||
id: "row-12",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "Row 12",
|
||||
});
|
||||
const pending = {
|
||||
draftId: "draft",
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: descriptor.id,
|
||||
agentId: null,
|
||||
clientMessageId: "message",
|
||||
text: "Create",
|
||||
timestamp: 1234,
|
||||
lifecycle: "active" as const,
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
|
||||
{ initialProps: { last: items[11] } },
|
||||
);
|
||||
expect(result.current.visibleItems).toHaveLength(7);
|
||||
const creating = createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: descriptor,
|
||||
pendingCreateAttempts: { draft: pending },
|
||||
});
|
||||
expect(creating.statusBucket).toBe("running");
|
||||
rerender({ last: creating });
|
||||
expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]);
|
||||
const abandoned = createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: descriptor,
|
||||
pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } },
|
||||
});
|
||||
rerender({ last: abandoned });
|
||||
expect(result.current.visibleItems).toHaveLength(7);
|
||||
rerender({ last: creating });
|
||||
for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) {
|
||||
const last = createSidebarWorkspaceEntry({
|
||||
serverId: SERVER_ID,
|
||||
workspace: descriptor,
|
||||
workspaceAgentActivity: new Map([
|
||||
[descriptor.id, { agentId: "agent", status, enteredAt: null }],
|
||||
]),
|
||||
});
|
||||
expect(last.statusBucket).toBe(status);
|
||||
rerender({ last });
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
act(() => result.current.toggleExpanded());
|
||||
expect(result.current.visibleItems).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps {
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
isArchiving: boolean;
|
||||
isCreating?: boolean;
|
||||
@@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({
|
||||
];
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
@@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1403,7 +1401,6 @@ function WorkspaceRowItem({
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
onWorkspacePress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
@@ -1412,9 +1409,8 @@ function WorkspaceRowItem({
|
||||
if (!workspace.serverId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
@@ -1436,7 +1432,7 @@ function WorkspaceRowItem({
|
||||
enabled: selectionEnabled,
|
||||
})}
|
||||
onPress={handlePress}
|
||||
drag={drag ?? noop}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -1465,7 +1461,7 @@ function areWorkspaceRowItemPropsEqual(
|
||||
previous.hostBadge === next.hostBadge &&
|
||||
previous.leadingProjectName === next.leadingProjectName &&
|
||||
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
(!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
previous.canCopyBranchName === next.canCopyBranchName &&
|
||||
previous.canPin === next.canPin &&
|
||||
@@ -1507,7 +1503,7 @@ function WorkspaceRow({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1552,16 +1548,12 @@ function ProjectBlock({
|
||||
selectionEnabled,
|
||||
showShortcutBadges,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
parentGestureRef,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostBadgeByServerId,
|
||||
@@ -1577,16 +1569,12 @@ function ProjectBlock({
|
||||
selectionEnabled: boolean;
|
||||
showShortcutBadges: boolean;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -1594,12 +1582,20 @@ function ProjectBlock({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const visibilityPolicy = useMemo(
|
||||
() => ({
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
|
||||
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
|
||||
}),
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
||||
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
||||
@@ -1628,16 +1624,10 @@ function ProjectBlock({
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => {
|
||||
(item: SidebarWorkspacePlacement) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={item.workspaceKey}
|
||||
workspace={item}
|
||||
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
|
||||
@@ -1650,9 +1640,6 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -1671,29 +1658,6 @@ function ProjectBlock({
|
||||
],
|
||||
);
|
||||
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderWorkspaceRow],
|
||||
);
|
||||
|
||||
const handleWorkspaceDragEnd = useCallback(
|
||||
(workspaces: SidebarWorkspacePlacement[]) => {
|
||||
onWorkspaceReorder(project.viewKey, workspaces);
|
||||
},
|
||||
[onWorkspaceReorder, project.viewKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
@@ -1759,20 +1723,12 @@ function ProjectBlock({
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
<DraggableList
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
</View>
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
@@ -1834,33 +1790,54 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
|
||||
projectWorkspaceEntriesEqual(previous, next) &&
|
||||
previous.collapsed === next.collapsed &&
|
||||
previous.displayName === next.displayName &&
|
||||
previous.iconDataUri === next.iconDataUri &&
|
||||
previous.selectionEnabled === next.selectionEnabled &&
|
||||
previous.showShortcutBadges === next.showShortcutBadges &&
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||
projectShortcutBadgesEqual(previous, next) &&
|
||||
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
|
||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
|
||||
areProjectBlockSelectionsEqual(previous, next)
|
||||
);
|
||||
}
|
||||
|
||||
function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
if (
|
||||
!next.showShortcutBadges ||
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey
|
||||
)
|
||||
return true;
|
||||
return next.project.workspaces.every(
|
||||
({ workspaceKey }) =>
|
||||
previous.shortcutIndexByWorkspaceKey.get(workspaceKey) ===
|
||||
next.shortcutIndexByWorkspaceKey.get(workspaceKey),
|
||||
);
|
||||
}
|
||||
|
||||
function projectWorkspaceEntriesEqual(
|
||||
previous: ProjectBlockProps,
|
||||
next: ProjectBlockProps,
|
||||
): boolean {
|
||||
if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true;
|
||||
return next.project.workspaces.every(
|
||||
({ workspaceKey }) =>
|
||||
previous.workspaceEntriesByKey.get(workspaceKey) ===
|
||||
next.workspaceEntriesByKey.get(workspaceKey),
|
||||
);
|
||||
}
|
||||
|
||||
function areProjectBlockSelectionsEqual(
|
||||
previous: ProjectBlockProps,
|
||||
next: ProjectBlockProps,
|
||||
@@ -2137,8 +2114,6 @@ function ProjectModeList({
|
||||
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
||||
|
||||
const isWorkspaceRoute = useMemo(
|
||||
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
||||
@@ -2236,30 +2211,6 @@ function ProjectModeList({
|
||||
[getProjectOrder, setProjectOrder],
|
||||
);
|
||||
|
||||
const handleWorkspaceReorder = useCallback(
|
||||
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceOrder(
|
||||
projectViewKey,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getWorkspaceOrder, setWorkspaceOrder],
|
||||
);
|
||||
|
||||
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -2306,16 +2257,12 @@ function ProjectModeList({
|
||||
selectionEnabled={selectionEnabled}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
@@ -2329,15 +2276,12 @@ function ProjectModeList({
|
||||
collapsedProjectKeys,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
projectIconByProjectViewKey,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
|
||||
@@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
const hosts = useHosts();
|
||||
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
|
||||
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
|
||||
const { allProjects, resolvedProjectFilters } = useSidebarModel();
|
||||
const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel();
|
||||
const { labels } = useWorkspaceLabelProjection();
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const openManager = useCallback(() => setManagerOpen(true), []);
|
||||
@@ -240,6 +240,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
},
|
||||
];
|
||||
|
||||
if (preferences.grouping === "project") {
|
||||
definitions.push({
|
||||
id: "sort",
|
||||
title: t("sidebar.display.sort.label"),
|
||||
content: workspaceSortSupported ? (
|
||||
<SortPage preferences={preferences} />
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (showHostFilter) {
|
||||
definitions.push({
|
||||
id: "hostFilter",
|
||||
@@ -273,6 +285,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
}, [
|
||||
t,
|
||||
preferences,
|
||||
workspaceSortSupported,
|
||||
hosts,
|
||||
showHostFilter,
|
||||
showProjectFilter,
|
||||
@@ -308,6 +321,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
>
|
||||
{t("sidebar.display.grouping.label")}
|
||||
</MenuSubTrigger>
|
||||
{preferences.grouping === "project" ? (
|
||||
<MenuSubTrigger
|
||||
id="sort"
|
||||
value={
|
||||
workspaceSortSupported
|
||||
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("sidebar.display.sort.label")}
|
||||
</MenuSubTrigger>
|
||||
) : null}
|
||||
<MenuSubTrigger
|
||||
id="titleSource"
|
||||
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
|
||||
@@ -374,6 +399,23 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { setWorkspaceSort } = preferences;
|
||||
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
|
||||
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label you could filter by, wherever it lives, one row each.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceSort } from "@/projects/workspace-structure";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useAppSettings,
|
||||
@@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences {
|
||||
showBackground: boolean;
|
||||
toggleBackground: () => void;
|
||||
grouping: SidebarGroupMode;
|
||||
workspaceSort: WorkspaceSort;
|
||||
setWorkspaceSort: (sort: WorkspaceSort) => void;
|
||||
setGrouping: (mode: SidebarGroupMode) => void;
|
||||
titleSource: WorkspaceTitleSource;
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
@@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences {
|
||||
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
|
||||
const workspaceSort = useSidebarViewStore((state) => state.workspaceSort);
|
||||
const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort);
|
||||
const grouping = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
@@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
workspaceSort,
|
||||
setWorkspaceSort,
|
||||
setGrouping,
|
||||
titleSource: workspaceTitleSource,
|
||||
setTitleSource,
|
||||
@@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
workspaceSort,
|
||||
setWorkspaceSort,
|
||||
setGrouping,
|
||||
workspaceTitleSource,
|
||||
setTitleSource,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||
|
||||
export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
|
||||
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
policy: SidebarGroupVisibilityPolicy<T>,
|
||||
) {
|
||||
const limit = policy.kind === "pinned" ? 20 : 7;
|
||||
const baseline = items.filter(
|
||||
(item, index) =>
|
||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
|
||||
);
|
||||
return {
|
||||
visibleItems: expanded ? items.slice() : baseline,
|
||||
canToggle: baseline.length < items.length,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function SidebarModelProvider({
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
|
||||
? [{ ...project, workspaces }]
|
||||
? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,14 +145,13 @@ function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
function SidebarSearchRow() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
setCommandCenterOpen(true);
|
||||
}, [onBeforeNavigate, setCommandCenterOpen]);
|
||||
}, [setCommandCenterOpen]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
|
||||
@@ -319,6 +319,11 @@ function StatusGroupList({
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_GROUP_VISIBILITY = {
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspaceEntry) => workspace.statusBucket,
|
||||
};
|
||||
|
||||
function StatusGroupRows({
|
||||
group,
|
||||
collapsed,
|
||||
@@ -345,7 +350,7 @@ function StatusGroupRows({
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(group.rows);
|
||||
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
|
||||
|
||||
return (
|
||||
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
|
||||
@@ -507,7 +512,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onWorkspacePress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
@@ -538,9 +542,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
|
||||
@@ -41,8 +41,6 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
|
||||
function noop() {}
|
||||
|
||||
interface SidebarWorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
@@ -239,7 +237,7 @@ function WorkspaceRowBody({
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noop,
|
||||
drag,
|
||||
menuController: null,
|
||||
});
|
||||
const {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupVisibilityPolicy,
|
||||
} from "./sidebar-group-visibility";
|
||||
|
||||
const INITIAL_VISIBLE_ITEMS = 20;
|
||||
const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
|
||||
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
|
||||
export function useLimitedSidebarGroup<T>(
|
||||
items: readonly T[],
|
||||
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
|
||||
) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleItems = useMemo(
|
||||
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
|
||||
[expanded, items],
|
||||
const visibility = useMemo(
|
||||
() => selectSidebarGroupVisibility(items, expanded, policy),
|
||||
[expanded, items, policy],
|
||||
);
|
||||
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { visibleItems, expanded, canToggle, toggleExpanded };
|
||||
return { ...visibility, expanded, toggleExpanded };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import type { useContextMenu } from "@/components/ui/context-menu";
|
||||
|
||||
export function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
drag?: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const { drag, menuController } = input;
|
||||
@@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: {
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
if (platformIsWeb) {
|
||||
if (platformIsWeb || !drag) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { CircleAlert } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface StatusIconProps {
|
||||
children: ReactNode;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
size?: number;
|
||||
statusDotBorderColor?: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}
|
||||
const DEFAULT_STATUS_DOT_OFFSET = -2;
|
||||
const STATUS_ALERT_OFFSET = -3;
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const needsInputAlertMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
export function StatusIcon({
|
||||
children,
|
||||
statusBucket,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
backdrop,
|
||||
}: StatusIconProps): ReactElement {
|
||||
const bucket = statusBucket;
|
||||
const isRunning = bucket === "running";
|
||||
let statusDotColor: string | undefined;
|
||||
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const showNeedsInputAlert = bucket === "needs_input";
|
||||
const agentIconWrapperStyle = useMemo(
|
||||
() => [styles.agentIconWrapper, { width: size, height: size }],
|
||||
[size],
|
||||
);
|
||||
const statusDotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? styles[backdrop].borderColor,
|
||||
},
|
||||
],
|
||||
[statusDotColor, statusDotBorderColor, backdrop],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
{children}
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={styles.statusRing}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel="Agent running"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
) : null}
|
||||
{statusDotColor ? <View style={statusDotStyle} /> : null}
|
||||
{showNeedsInputAlert ? (
|
||||
<View style={styles.statusAlertOverlay}>
|
||||
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
agentIconWrapper: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusRing: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
},
|
||||
surface0: { borderColor: theme.colors.surface0 },
|
||||
surface1: { borderColor: theme.colors.surface1 },
|
||||
surface2: { borderColor: theme.colors.surface2 },
|
||||
surfaceSidebar: { borderColor: theme.colors.surfaceSidebar },
|
||||
surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover },
|
||||
surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected },
|
||||
statusAlertOverlay: {
|
||||
position: "absolute",
|
||||
right: STATUS_ALERT_OFFSET,
|
||||
bottom: STATUS_ALERT_OFFSET,
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
}));
|
||||
@@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
export interface TerminalPasteActionProps {
|
||||
hasClipboardText: boolean;
|
||||
/** Clipboard holds text or an image, so a paste attempt can do something. */
|
||||
hasClipboardContent: boolean;
|
||||
onPaste: () => void;
|
||||
}
|
||||
|
||||
export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
|
||||
export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) {
|
||||
return (
|
||||
<TerminalActionButton
|
||||
label="Paste"
|
||||
accessibilityLabel="Paste"
|
||||
testID="terminal-paste"
|
||||
disabled={!hasClipboardText}
|
||||
disabled={!hasClipboardContent}
|
||||
onPress={onPaste}
|
||||
variant="key"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
TerminalLocalFileLinkTarget,
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
|
||||
@@ -75,4 +76,5 @@ export interface TerminalEmulatorProps {
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
|
||||
}
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type {
|
||||
TerminalOutputData,
|
||||
TerminalFindResult,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract";
|
||||
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
|
||||
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
|
||||
|
||||
type BridgeInboundMessage =
|
||||
| {
|
||||
type: "mount";
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
| { type: "unmount"; streamKey: string }
|
||||
| { type: "writeOutput"; streamKey: string; text: string }
|
||||
| { type: "restoreOutput"; streamKey: string; text: string }
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "paste"; streamKey: string; text: string }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
|
||||
| { type: "clearFind"; streamKey: string }
|
||||
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
|
||||
| {
|
||||
type: "resolveLocalFileLinkResponse";
|
||||
streamKey: string;
|
||||
requestId: number;
|
||||
target: TerminalLocalFileLinkTarget | null;
|
||||
};
|
||||
|
||||
type BridgeOutboundMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "rendererReady"; streamKey: string; isReady: boolean }
|
||||
| { type: "input"; streamKey: string; data: string }
|
||||
| { type: "findRequest"; streamKey: string }
|
||||
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
|
||||
| {
|
||||
type: "resize";
|
||||
streamKey: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim?: boolean;
|
||||
forceClaim?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "terminalKey";
|
||||
streamKey: string;
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
| { type: "pendingModifiersConsumed"; streamKey: string }
|
||||
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
|
||||
| { type: "openExternalUrl"; streamKey: string; url: string }
|
||||
| {
|
||||
type: "resolveLocalFileLink";
|
||||
streamKey: string;
|
||||
requestId: number;
|
||||
source: TerminalLocalFileLinkSource;
|
||||
}
|
||||
| {
|
||||
type: "openLocalFileLink";
|
||||
streamKey: string;
|
||||
target: TerminalLocalFileLinkTarget;
|
||||
disposition: "main" | "side";
|
||||
}
|
||||
| { type: "swipeLeft"; streamKey: string }
|
||||
| { type: "swipeRight"; streamKey: string }
|
||||
| { type: "debug"; message: string; details?: unknown };
|
||||
|
||||
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
|
||||
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
|
||||
const BRIDGE_READY_TIMEOUT_MS = 2_500;
|
||||
const RENDERER_READY_TIMEOUT_MS = 2_500;
|
||||
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
|
||||
type WebViewProps = ComponentProps<typeof WebView>;
|
||||
interface PendingTerminalTap {
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
function buildThemeKey(theme: ITheme): string {
|
||||
return JSON.stringify(theme);
|
||||
}
|
||||
|
||||
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
|
||||
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
|
||||
function createMountMessage(input: {
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}): BridgeInboundMessage {
|
||||
return {
|
||||
type: "mount",
|
||||
streamKey: input.streamKey,
|
||||
initialSnapshot: input.initialSnapshot,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
theme: input.theme,
|
||||
fontFamily: input.fontFamily,
|
||||
fontSize: input.fontSize,
|
||||
pendingModifiers: input.pendingModifiers,
|
||||
swipeGesturesEnabled: input.swipeGesturesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WebViewTerminalEmulator({
|
||||
ref,
|
||||
streamKey,
|
||||
testId = "terminal-surface",
|
||||
xtermTheme = {
|
||||
background: "#0b0b0b",
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
}: TerminalEmulatorProps) {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const [webViewEpoch, setWebViewEpoch] = useState(0);
|
||||
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
|
||||
const bridgeReadyRef = useRef(false);
|
||||
const bridgeReadyVersionRef = useRef(0);
|
||||
const rendererReadyVersionRef = useRef(0);
|
||||
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const mountRequestedStreamKeyRef = useRef<string | null>(null);
|
||||
const rendererReadyStreamKeyRef = useRef<string | null>(null);
|
||||
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
|
||||
const mountConfigRef = useRef({
|
||||
streamKey,
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
});
|
||||
mountConfigRef.current = {
|
||||
streamKey,
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
};
|
||||
const callbacksRef = useRef({
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
};
|
||||
|
||||
const clearBridgeReadyTimeout = useCallback(() => {
|
||||
if (bridgeReadyTimeoutRef.current === null) return;
|
||||
clearTimeout(bridgeReadyTimeoutRef.current);
|
||||
bridgeReadyTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearRendererReadyTimeout = useCallback(() => {
|
||||
if (rendererReadyTimeoutRef.current === null) return;
|
||||
clearTimeout(rendererReadyTimeoutRef.current);
|
||||
rendererReadyTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetWebViewDocument = useCallback(() => {
|
||||
clearBridgeReadyTimeout();
|
||||
clearRendererReadyTimeout();
|
||||
bridgeReadyRef.current = false;
|
||||
pendingMessagesRef.current = [];
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
setWebViewEpoch((value) => value + 1);
|
||||
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
|
||||
|
||||
const scheduleBridgeReadyWatchdog = useCallback(() => {
|
||||
clearBridgeReadyTimeout();
|
||||
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
|
||||
bridgeReadyTimeoutRef.current = setTimeout(() => {
|
||||
bridgeReadyTimeoutRef.current = null;
|
||||
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
resetWebViewDocument();
|
||||
}, BRIDGE_READY_TIMEOUT_MS);
|
||||
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
|
||||
|
||||
const scheduleRendererReadyWatchdog = useCallback(() => {
|
||||
clearRendererReadyTimeout();
|
||||
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
|
||||
rendererReadyTimeoutRef.current = setTimeout(() => {
|
||||
rendererReadyTimeoutRef.current = null;
|
||||
if (
|
||||
!shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: expectedRendererReadyVersion,
|
||||
currentReadyVersion: rendererReadyVersionRef.current,
|
||||
expectedStreamKey: streamKey,
|
||||
mountRequestedStreamKey: mountRequestedStreamKeyRef.current,
|
||||
rendererReadyStreamKey: rendererReadyStreamKeyRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
resetWebViewDocument();
|
||||
}, RENDERER_READY_TIMEOUT_MS);
|
||||
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
if (!bridgeReadyRef.current || !webViewRef.current) return;
|
||||
const pending = pendingMessagesRef.current.splice(0);
|
||||
for (const message of pending) {
|
||||
const payload = serializeForInjectedJavaScript(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
|
||||
if (!bridgeReadyRef.current || !webViewRef.current) {
|
||||
pendingMessagesRef.current.push(message);
|
||||
return;
|
||||
}
|
||||
const payload = serializeForInjectedJavaScript(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
find: {
|
||||
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
|
||||
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
|
||||
clear: () => sendToWebView({ type: "clearFind", streamKey }),
|
||||
},
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
const output = outputDecoderRef.current.decode(data, { stream: true });
|
||||
if (output.length === 0) {
|
||||
return;
|
||||
}
|
||||
sendToWebView({ type: "writeOutput", streamKey, text: output });
|
||||
},
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
outputDecoderRef.current.decode();
|
||||
const text = outputDecoderRef.current.decode(data, { stream: false });
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
sendToWebView({ type: "restoreOutput", streamKey, text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
outputDecoderRef.current.decode();
|
||||
sendToWebView({ type: "renderSnapshot", streamKey, state });
|
||||
},
|
||||
paste: (text: string) => {
|
||||
sendToWebView({ type: "paste", streamKey, text });
|
||||
},
|
||||
copySelection: async () => "",
|
||||
clear: () => {
|
||||
outputDecoderRef.current.decode();
|
||||
sendToWebView({ type: "clear", streamKey });
|
||||
},
|
||||
claimSize: () => {
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: true });
|
||||
},
|
||||
showKeyboard: () => {
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
webViewRef.current?.requestFocus();
|
||||
},
|
||||
blur: () => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
|
||||
);
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
}),
|
||||
[sendToWebView, streamKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
outputDecoderRef.current.decode();
|
||||
}, [streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bridgeReadyVersion <= 0) return;
|
||||
const mountMessage = createMountMessage(mountConfigRef.current);
|
||||
mountRequestedStreamKeyRef.current = streamKey;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
sendToWebView(mountMessage);
|
||||
flushPendingMessages();
|
||||
scheduleRendererReadyWatchdog();
|
||||
}, [
|
||||
bridgeReadyVersion,
|
||||
flushPendingMessages,
|
||||
scheduleRendererReadyWatchdog,
|
||||
sendToWebView,
|
||||
streamKey,
|
||||
]);
|
||||
|
||||
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
|
||||
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
|
||||
}, [scrollbackLines, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
|
||||
}, [fontFamily, fontSize, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
|
||||
}, [pendingModifiers, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
|
||||
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: true });
|
||||
sendToWebView({ type: "focus", streamKey });
|
||||
webViewRef.current?.requestFocus();
|
||||
}, [focusRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resizeRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false });
|
||||
}, [resizeRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mountRequestedStreamKeyRef.current) {
|
||||
const previousStreamKey = mountRequestedStreamKeyRef.current;
|
||||
callbacksRef.current.onRendererReadyChange?.({
|
||||
streamKey: previousStreamKey,
|
||||
isReady: false,
|
||||
});
|
||||
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
|
||||
}
|
||||
bridgeReadyRef.current = false;
|
||||
pendingMessagesRef.current = [];
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
clearBridgeReadyTimeout();
|
||||
clearRendererReadyTimeout();
|
||||
};
|
||||
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
|
||||
|
||||
const handleLifecycleMessage = useCallback(
|
||||
(message: BridgeOutboundMessage): boolean => {
|
||||
if (message.type === "bridgeReady") {
|
||||
bridgeReadyRef.current = true;
|
||||
bridgeReadyVersionRef.current += 1;
|
||||
clearBridgeReadyTimeout();
|
||||
setBridgeReadyVersion((value) => value + 1);
|
||||
return true;
|
||||
}
|
||||
if (message.type === "rendererReady") {
|
||||
if (message.streamKey === mountRequestedStreamKeyRef.current) {
|
||||
rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null;
|
||||
if (message.isReady) {
|
||||
rendererReadyVersionRef.current += 1;
|
||||
clearRendererReadyTimeout();
|
||||
}
|
||||
}
|
||||
callbacksRef.current.onRendererReadyChange?.({
|
||||
streamKey: message.streamKey,
|
||||
isReady: message.isReady,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
|
||||
);
|
||||
|
||||
const resolveLocalFileLink = useCallback(
|
||||
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
|
||||
try {
|
||||
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
|
||||
null);
|
||||
sendToWebView({
|
||||
type: "resolveLocalFileLinkResponse",
|
||||
streamKey: message.streamKey,
|
||||
requestId: message.requestId,
|
||||
target,
|
||||
});
|
||||
} catch {
|
||||
sendToWebView({
|
||||
type: "resolveLocalFileLinkResponse",
|
||||
streamKey: message.streamKey,
|
||||
requestId: message.requestId,
|
||||
target: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendToWebView],
|
||||
);
|
||||
|
||||
const handleTerminalMessage = useCallback(
|
||||
(
|
||||
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
||||
) => {
|
||||
if (message.type === "resolveLocalFileLink") {
|
||||
void resolveLocalFileLink(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "openLocalFileLink") {
|
||||
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
|
||||
return;
|
||||
}
|
||||
switch (message.type) {
|
||||
case "input":
|
||||
callbacksRef.current.onInput?.(message.data);
|
||||
break;
|
||||
case "resize":
|
||||
callbacksRef.current.onResize?.({
|
||||
rows: message.rows,
|
||||
cols: message.cols,
|
||||
shouldClaim: message.shouldClaim !== false,
|
||||
forceClaim: message.forceClaim,
|
||||
});
|
||||
break;
|
||||
case "terminalKey":
|
||||
callbacksRef.current.onTerminalKey?.({
|
||||
key: message.key,
|
||||
ctrl: message.ctrl,
|
||||
shift: message.shift,
|
||||
alt: message.alt,
|
||||
meta: message.meta,
|
||||
});
|
||||
break;
|
||||
case "pendingModifiersConsumed":
|
||||
callbacksRef.current.onPendingModifiersConsumed?.();
|
||||
break;
|
||||
case "inputModeChange":
|
||||
callbacksRef.current.onInputModeChange?.(message.state);
|
||||
break;
|
||||
case "openExternalUrl":
|
||||
void openExternalUrl(message.url);
|
||||
break;
|
||||
case "swipeLeft":
|
||||
callbacksRef.current.onSwipeLeft?.();
|
||||
break;
|
||||
case "swipeRight":
|
||||
callbacksRef.current.onSwipeRight?.();
|
||||
break;
|
||||
case "debug":
|
||||
break;
|
||||
}
|
||||
},
|
||||
[resolveLocalFileLink],
|
||||
);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let message: BridgeOutboundMessage;
|
||||
try {
|
||||
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "bridgeReady" || message.type === "rendererReady") {
|
||||
handleLifecycleMessage(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldHandleTerminalBridgeMessage(message, {
|
||||
desiredStreamKey: streamKey,
|
||||
mountedStreamKey: mountRequestedStreamKeyRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "findRequest") {
|
||||
callbacksRef.current.onFindRequest?.();
|
||||
return;
|
||||
}
|
||||
if (message.type === "findResult") {
|
||||
callbacksRef.current.onFindResult?.(message.result);
|
||||
return;
|
||||
}
|
||||
handleTerminalMessage(message);
|
||||
},
|
||||
[handleLifecycleMessage, handleTerminalMessage, streamKey],
|
||||
);
|
||||
|
||||
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
scheduleBridgeReadyWatchdog();
|
||||
}, [scheduleBridgeReadyWatchdog]);
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback<
|
||||
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleRenderProcessGone = useCallback<
|
||||
NonNullable<WebViewProps["onRenderProcessGone"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
|
||||
pendingTapRef.current = {
|
||||
startX: event.nativeEvent.pageX,
|
||||
startY: event.nativeEvent.pageY,
|
||||
moved: false,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
|
||||
const pendingTap = pendingTapRef.current;
|
||||
if (!pendingTap) return;
|
||||
const dx = event.nativeEvent.pageX - pendingTap.startX;
|
||||
const dy = event.nativeEvent.pageY - pendingTap.startY;
|
||||
if (
|
||||
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
|
||||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
|
||||
) {
|
||||
pendingTap.moved = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWebViewTouchEnd = useCallback(() => {
|
||||
const pendingTap = pendingTapRef.current;
|
||||
pendingTapRef.current = null;
|
||||
if (!pendingTap || pendingTap.moved) {
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.requestFocus();
|
||||
callbacksRef.current.onFocus?.();
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
}, [sendToWebView, streamKey]);
|
||||
|
||||
const handleWebViewTouchCancel = useCallback(() => {
|
||||
pendingTapRef.current = null;
|
||||
}, []);
|
||||
|
||||
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
|
||||
const rootStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webView, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webViewContainer, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID={testId}>
|
||||
<WebView
|
||||
key={webViewEpoch}
|
||||
ref={webViewRef}
|
||||
source={TERMINAL_WEBVIEW_SOURCE}
|
||||
style={webViewStyle}
|
||||
containerStyle={webViewContainerStyle}
|
||||
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
|
||||
scrollEnabled
|
||||
nestedScrollEnabled
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
textInteractionEnabled
|
||||
allowsLinkPreview={false}
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
onTouchStart={handleWebViewTouchStart}
|
||||
onTouchMove={handleWebViewTouchMove}
|
||||
onTouchEnd={handleWebViewTouchEnd}
|
||||
onTouchCancel={handleWebViewTouchCancel}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,8 @@ import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAppSettings } from "../hooks/use-settings";
|
||||
import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability";
|
||||
import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native";
|
||||
import WebViewTerminalEmulator from "./terminal-emulator-webview.native";
|
||||
import type { TerminalEmulatorProps } from "./terminal-emulator-contract";
|
||||
|
||||
export type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
|
||||
@@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina
|
||||
}
|
||||
|
||||
export default function TerminalEmulator(props: TerminalEmulatorProps) {
|
||||
const { settings } = useAppSettings();
|
||||
const { useLegacyTerminalRenderer } = settings;
|
||||
const rendererSelection = resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer,
|
||||
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
|
||||
});
|
||||
|
||||
@@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) {
|
||||
return <UnsupportedNativeTerminal {...props} />;
|
||||
}
|
||||
|
||||
const Renderer =
|
||||
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
|
||||
|
||||
return <Renderer key={rendererSelection} {...props} />;
|
||||
return <NativeGridTerminalEmulator {...props} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
@@ -29,6 +27,7 @@ import {
|
||||
type TerminalOutputData,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
@@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
extractTerminalDropPaths,
|
||||
isTerminalDragLeaveOutside,
|
||||
isTerminalFileDrag,
|
||||
prepareDroppedPathsForTerminal,
|
||||
} from "../terminal/drop/terminal-file-drop";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
@@ -145,6 +137,7 @@ interface TerminalEmulatorProps {
|
||||
disposition: "main" | "side",
|
||||
) => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -191,6 +184,7 @@ export default function TerminalEmulator({
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onRendererReadyChange,
|
||||
onFilePaste,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
@@ -220,6 +214,7 @@ export default function TerminalEmulator({
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
});
|
||||
mountCallbacksRef.current = {
|
||||
onFindRequest,
|
||||
@@ -231,13 +226,12 @@ export default function TerminalEmulator({
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
};
|
||||
const initialSnapshotRef = useRef(initialSnapshot);
|
||||
initialSnapshotRef.current = initialSnapshot;
|
||||
const pendingModifiersRef = useRef(pendingModifiers);
|
||||
pendingModifiersRef.current = pendingModifiers;
|
||||
const [isDropActive, setIsDropActive] = useState(false);
|
||||
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||
const pasteText = useCallback((text: string) => {
|
||||
@@ -506,11 +500,13 @@ export default function TerminalEmulator({
|
||||
onInputModeChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
onOpenExternalUrl: openExternalUrl,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
onFindRequest,
|
||||
onFilePaste,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onInputModeChange,
|
||||
@@ -582,111 +578,6 @@ export default function TerminalEmulator({
|
||||
[showTerminalContextMenu],
|
||||
);
|
||||
|
||||
const clearDropActiveTimeout = useCallback(() => {
|
||||
if (dropActiveTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(dropActiveTimeoutRef.current);
|
||||
dropActiveTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearTerminalDropActive = useCallback(() => {
|
||||
clearDropActiveTimeout();
|
||||
setIsDropActive(false);
|
||||
}, [clearDropActiveTimeout]);
|
||||
|
||||
const keepTerminalDropActive = useCallback(() => {
|
||||
clearDropActiveTimeout();
|
||||
setIsDropActive(true);
|
||||
dropActiveTimeoutRef.current = setTimeout(() => {
|
||||
dropActiveTimeoutRef.current = null;
|
||||
setIsDropActive(false);
|
||||
}, 180);
|
||||
}, [clearDropActiveTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
keepTerminalDropActive();
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
keepTerminalDropActive();
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearTerminalDropActive();
|
||||
|
||||
const bridge = getDesktopHost();
|
||||
const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeRef.current?.focus();
|
||||
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
|
||||
};
|
||||
|
||||
root.addEventListener("dragenter", handleDragEnter, { capture: true });
|
||||
root.addEventListener("dragover", handleDragOver, { capture: true });
|
||||
root.addEventListener("drop", handleDrop, { capture: true });
|
||||
window.addEventListener("dragend", clearTerminalDropActive);
|
||||
window.addEventListener("drop", clearTerminalDropActive);
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("dragenter", handleDragEnter, { capture: true });
|
||||
root.removeEventListener("dragover", handleDragOver, { capture: true });
|
||||
root.removeEventListener("drop", handleDrop, { capture: true });
|
||||
window.removeEventListener("dragend", clearTerminalDropActive);
|
||||
window.removeEventListener("drop", clearTerminalDropActive);
|
||||
clearDropActiveTimeout();
|
||||
};
|
||||
}, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]);
|
||||
|
||||
const handleRootDragLeave = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
!isTerminalDragLeaveOutside({
|
||||
currentTarget: event.currentTarget,
|
||||
relatedTarget: event.relatedTarget,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearTerminalDropActive();
|
||||
},
|
||||
[clearTerminalDropActive],
|
||||
);
|
||||
|
||||
const rootDivStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
position: "relative",
|
||||
@@ -702,19 +593,6 @@ export default function TerminalEmulator({
|
||||
}),
|
||||
[xtermTheme.background],
|
||||
);
|
||||
const dropOverlayStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 9,
|
||||
border: "1px solid rgba(78, 161, 255, 0.72)",
|
||||
backgroundColor: "rgba(78, 161, 255, 0.16)",
|
||||
opacity: isDropActive ? 1 : 0,
|
||||
pointerEvents: "none",
|
||||
transition: "opacity 120ms ease-out",
|
||||
}),
|
||||
[isDropActive],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -723,10 +601,8 @@ export default function TerminalEmulator({
|
||||
style={rootDivStyle}
|
||||
onPointerDown={handleRootPointerDown}
|
||||
onContextMenu={handleRootContextMenu}
|
||||
onDragLeave={handleRootDragLeave}
|
||||
>
|
||||
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
||||
<div style={dropOverlayStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,23 @@ import {
|
||||
shouldShowTerminalPasteAction,
|
||||
type TerminalVirtualKeyboardControl,
|
||||
} from "@/terminal/runtime/terminal-virtual-keyboard";
|
||||
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
|
||||
import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
|
||||
import {
|
||||
TerminalFilePasteQueue,
|
||||
TerminalFileTooLargeError,
|
||||
terminalFileFromBase64,
|
||||
type TerminalFile,
|
||||
} from "@/terminal/file-paste";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { DismissToast } from "@/components/toast-host";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
|
||||
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
EMPTY_FOCUS_CLAIM_STATE,
|
||||
@@ -122,6 +138,24 @@ type PendingTerminalInput =
|
||||
input: TerminalKeyInput;
|
||||
};
|
||||
|
||||
async function readNativeClipboardImage(): Promise<TerminalFile | null> {
|
||||
const image = await Clipboard.getImageAsync({ format: "png" });
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
return terminalFileFromBase64({
|
||||
fileName: "clipboard.png",
|
||||
mimeType: "image/png",
|
||||
data: image.data.slice(image.data.indexOf(",") + 1),
|
||||
});
|
||||
}
|
||||
|
||||
/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */
|
||||
function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) {
|
||||
useFileDrop({ onGenericFiles: onFiles });
|
||||
return null;
|
||||
}
|
||||
|
||||
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
@@ -292,9 +326,47 @@ export function TerminalPane({
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [hasClipboardText, setHasClipboardText] = useState(false);
|
||||
const [hasClipboardImage, setHasClipboardImage] = useState(false);
|
||||
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
const toast = useToast();
|
||||
const dismissUploadingToastRef = useRef<DismissToast | null>(null);
|
||||
const showFileUploading = useStableEvent((uploading: boolean) => {
|
||||
dismissUploadingToastRef.current?.();
|
||||
dismissUploadingToastRef.current = uploading
|
||||
? toast.show(t("workspace.terminal.uploadingFile"), {
|
||||
durationMs: null,
|
||||
testID: "terminal-file-uploading",
|
||||
})
|
||||
: null;
|
||||
});
|
||||
const reportFileUploaded = useStableEvent(() => {
|
||||
toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" });
|
||||
});
|
||||
const reportFilePasteFailure = useStableEvent((error: unknown) => {
|
||||
const reason =
|
||||
error instanceof TerminalFileTooLargeError
|
||||
? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName })
|
||||
: toErrorMessage(error);
|
||||
toast.error(t("workspace.terminal.pasteFileFailed", { reason }));
|
||||
});
|
||||
const [filePastes] = useState(
|
||||
() =>
|
||||
new TerminalFilePasteQueue({
|
||||
onUploadingChange: showFileUploading,
|
||||
onUploaded: reportFileUploaded,
|
||||
onFailure: reportFilePasteFailure,
|
||||
}),
|
||||
);
|
||||
useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]);
|
||||
useEffect(() => {
|
||||
if (!isConnected && filePastes.cancel()) {
|
||||
reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected")));
|
||||
}
|
||||
}, [filePastes, isConnected, reportFilePasteFailure, t]);
|
||||
// Only the desktop app's own daemon can read files on this machine by path.
|
||||
const isLocalHost = useIsLocalDaemon(serverId);
|
||||
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
|
||||
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||
const findRef = useRef<TerminalPaneFindHandle>(null);
|
||||
@@ -325,10 +397,15 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hasText = await Clipboard.hasStringAsync();
|
||||
const [hasText, hasImage] = await Promise.all([
|
||||
Clipboard.hasStringAsync(),
|
||||
Clipboard.hasImageAsync(),
|
||||
]);
|
||||
setHasClipboardText(hasText);
|
||||
setHasClipboardImage(hasImage);
|
||||
} catch {
|
||||
setHasClipboardText(false);
|
||||
setHasClipboardImage(false);
|
||||
}
|
||||
}, [showVirtualKeyBar]);
|
||||
|
||||
@@ -705,6 +782,9 @@ export function TerminalPane({
|
||||
alt: boolean;
|
||||
meta?: boolean;
|
||||
}): boolean => {
|
||||
if (input.ctrl && input.key.toLowerCase() === "c") {
|
||||
filePastes.cancel();
|
||||
}
|
||||
if (!client || !terminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "key",
|
||||
@@ -740,7 +820,7 @@ export function TerminalPane({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes],
|
||||
);
|
||||
|
||||
const handleTerminalData = useCallback(
|
||||
@@ -748,6 +828,10 @@ export function TerminalPane({
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Ctrl+C abandons file pastes that have not landed yet.
|
||||
if (data.includes("\x03")) {
|
||||
filePastes.cancel();
|
||||
}
|
||||
|
||||
if (hasPendingTerminalModifiers(modifiers)) {
|
||||
const pendingResolution = resolvePendingModifierDataInput({
|
||||
@@ -793,12 +877,59 @@ export function TerminalPane({
|
||||
clearPendingModifiers,
|
||||
client,
|
||||
dispatchTerminalInputEntry,
|
||||
filePastes,
|
||||
modifiers,
|
||||
sendTerminalKey,
|
||||
enqueuePendingTerminalInput,
|
||||
],
|
||||
);
|
||||
|
||||
const pasteTerminalText = useStableEvent((text: string) => {
|
||||
const entry: PendingTerminalInput = {
|
||||
type: "data",
|
||||
data: encodeTerminalPaste({
|
||||
text,
|
||||
bracketedPaste: inputModeRef.current.bracketedPaste ?? false,
|
||||
}),
|
||||
};
|
||||
if (!dispatchTerminalInputEntry(entry)) {
|
||||
enqueuePendingTerminalInput(entry);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFilePaste = useStableEvent((file: Promise<TerminalFile | null>) => {
|
||||
filePastes.add(file, {
|
||||
upload: async (upload) => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const response = await client.uploadFile(upload);
|
||||
if (!response.file) {
|
||||
throw new Error(response.error ?? "Upload failed");
|
||||
}
|
||||
return response.file.path;
|
||||
},
|
||||
paste: pasteTerminalText,
|
||||
});
|
||||
});
|
||||
|
||||
const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => {
|
||||
const bridge = getDesktopHost();
|
||||
const paths: string[] = [];
|
||||
for (const item of items) {
|
||||
const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge });
|
||||
if (delivery.kind === "path") {
|
||||
paths.push(delivery.path);
|
||||
} else {
|
||||
handleFilePaste(delivery.read());
|
||||
}
|
||||
}
|
||||
if (paths.length > 0) {
|
||||
pasteTerminalText(prepareDroppedPathsForTerminal(paths));
|
||||
}
|
||||
requestTerminalFocus();
|
||||
});
|
||||
|
||||
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
|
||||
const nextSize = { rows: input.rows, cols: input.cols };
|
||||
const claim = resolveTerminalResizeClaim({
|
||||
@@ -876,11 +1007,15 @@ export function TerminalPane({
|
||||
|
||||
const handleTerminalPaste = useCallback(() => {
|
||||
requestTerminalReflow();
|
||||
void pasteTerminalClipboard({
|
||||
const image = pasteTerminalClipboard({
|
||||
clipboard: { readText: () => Clipboard.getStringAsync() },
|
||||
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
|
||||
}).then(() => refreshClipboardAvailability());
|
||||
}, [requestTerminalReflow, refreshClipboardAvailability]);
|
||||
})
|
||||
.catch(() => false)
|
||||
.then((pastedText) => (pastedText ? null : readNativeClipboardImage()));
|
||||
handleFilePaste(image);
|
||||
void image.catch(() => null).then(() => refreshClipboardAvailability());
|
||||
}, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]);
|
||||
|
||||
const handleTerminalCopy = useCallback(() => {
|
||||
void emulatorRef.current
|
||||
@@ -1032,7 +1167,7 @@ export function TerminalPane({
|
||||
return showPasteAction ? (
|
||||
<TerminalPasteAction
|
||||
key={controlId}
|
||||
hasClipboardText={hasClipboardText}
|
||||
hasClipboardContent={hasClipboardText || hasClipboardImage}
|
||||
onPaste={handleTerminalPaste}
|
||||
/>
|
||||
) : null;
|
||||
@@ -1065,7 +1200,8 @@ export function TerminalPane({
|
||||
|
||||
return (
|
||||
<Animated.View style={containerStyle}>
|
||||
<View style={styles.outputContainer}>
|
||||
<FileDropZone style={styles.outputContainer}>
|
||||
<TerminalFileDropSink onFiles={handleDroppedFiles} />
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
@@ -1090,6 +1226,7 @@ export function TerminalPane({
|
||||
onFocus={handleTerminalFocus}
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
onFilePaste={handleFilePaste}
|
||||
onInputModeChange={handleInputModeChange}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onResolveLocalFileLink={handleResolveLocalFileLink}
|
||||
@@ -1122,7 +1259,7 @@ export function TerminalPane({
|
||||
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{streamError ? (
|
||||
<View style={styles.errorRow}>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
|
||||
|
||||
describe("terminal WebView message routing", () => {
|
||||
it("rejects terminal messages from a stale stream", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "resize", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the old WebView stream while React is switching callbacks", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts messages from the mounted stream and unscoped diagnostics", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-b" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "debug" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface TerminalBridgeMessageIdentity {
|
||||
type: string;
|
||||
streamKey?: string;
|
||||
}
|
||||
|
||||
export function shouldHandleTerminalBridgeMessage(
|
||||
message: TerminalBridgeMessageIdentity,
|
||||
stream: {
|
||||
desiredStreamKey: string;
|
||||
mountedStreamKey: string | null;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
message.streamKey === undefined ||
|
||||
(message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey)
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
|
||||
|
||||
describe("legacy terminal WebView readiness", () => {
|
||||
it("resets when a requested mount never reports renderer readiness", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the document after the requested renderer confirms readiness", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: "terminal-a",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a watchdog from an earlier ready generation", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 1,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let readiness from a stale stream satisfy the requested mount", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-b",
|
||||
mountRequestedStreamKey: "terminal-b",
|
||||
rendererReadyStreamKey: "terminal-a",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reset a newer mount from an expired watchdog", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-b",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export function shouldResetForMissingRendererReady(input: {
|
||||
expectedReadyVersion: number;
|
||||
currentReadyVersion: number;
|
||||
expectedStreamKey: string;
|
||||
mountRequestedStreamKey: string | null;
|
||||
rendererReadyStreamKey: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.currentReadyVersion === input.expectedReadyVersion &&
|
||||
input.mountRequestedStreamKey === input.expectedStreamKey &&
|
||||
input.rendererReadyStreamKey !== input.expectedStreamKey
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useToastHost } from "./toast-host";
|
||||
|
||||
describe("useToastHost", () => {
|
||||
it("dismisses the toast that show returned the dismiss for", () => {
|
||||
const { result } = renderHook(() => useToastHost());
|
||||
|
||||
let dismiss = () => {};
|
||||
act(() => {
|
||||
dismiss = result.current.api.show("Uploading image…", { durationMs: null });
|
||||
});
|
||||
expect(result.current.toast?.content).toBe("Uploading image…");
|
||||
|
||||
act(() => dismiss());
|
||||
expect(result.current.toast).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a newer toast in place when an older one is dismissed", () => {
|
||||
const { result } = renderHook(() => useToastHost());
|
||||
|
||||
let dismissUploading = () => {};
|
||||
act(() => {
|
||||
dismissUploading = result.current.api.show("Uploading image…", { durationMs: null });
|
||||
});
|
||||
act(() => {
|
||||
result.current.api.show("Image uploaded", { variant: "success" });
|
||||
});
|
||||
|
||||
act(() => dismissUploading());
|
||||
expect(result.current.toast?.content).toBe("Image uploaded");
|
||||
});
|
||||
});
|
||||
@@ -43,8 +43,11 @@ export interface ToastState {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
|
||||
export type DismissToast = () => void;
|
||||
|
||||
export interface ToastApi {
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => DismissToast;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
@@ -52,6 +55,7 @@ export interface ToastApi {
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
const noopDismiss: DismissToast = () => {};
|
||||
const TOAST_MAX_WIDTH = 480;
|
||||
const toastEntering = FadeIn.duration(140);
|
||||
const toastExiting = FadeOut.duration(140);
|
||||
@@ -73,10 +77,10 @@ export function useToastHost(): {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback((content: ReactNode, options?: ToastShowOptions) => {
|
||||
const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
|
||||
const nativeMessage = typeof content === "string" ? content.trim() : null;
|
||||
if (!content || nativeMessage === "") {
|
||||
return;
|
||||
return noopDismiss;
|
||||
}
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
@@ -87,12 +91,13 @@ export function useToastHost(): {
|
||||
const duration =
|
||||
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
|
||||
return;
|
||||
return noopDismiss;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
const id = idRef.current;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
id,
|
||||
content,
|
||||
nativeMessage,
|
||||
icon: options?.icon,
|
||||
@@ -100,6 +105,7 @@ export function useToastHost(): {
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
return () => setToast((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
|
||||
@@ -9,32 +9,20 @@ import {
|
||||
} from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, {
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RelativeAnchorRect {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -42,14 +30,6 @@ interface RelativeAnchorRect {
|
||||
hostHeight: number;
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
anchorRef: RefObject<View | null>;
|
||||
@@ -79,8 +59,9 @@ export function AutocompletePopover({
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const { shift, isMoving } = useKeyboardShift();
|
||||
const measuredShift = useSharedValue(0);
|
||||
const { isMoving } = useKeyboardShift();
|
||||
const anchor = useKeyboardAnchor();
|
||||
const { measureAtRest } = anchor;
|
||||
const measurementGeneration = useRef(0);
|
||||
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
@@ -90,20 +71,21 @@ export function AutocompletePopover({
|
||||
if (!anchorElement) return;
|
||||
const generation = measurementGeneration.current;
|
||||
void Promise.all([
|
||||
measureElement(anchorElement),
|
||||
measureAtRest(anchorElement),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([anchorRect, hostRect]) => {
|
||||
if (generation !== measurementGeneration.current || !hostRect) return undefined;
|
||||
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
|
||||
return undefined;
|
||||
}
|
||||
setRelativeAnchorRect({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
measuredShift.value = shift.value;
|
||||
return undefined;
|
||||
});
|
||||
}, [anchorRef, canMeasure, measuredShift, portalHostName, shift]);
|
||||
}, [anchorRef, canMeasure, measureAtRest, portalHostName]);
|
||||
|
||||
useEffect(() => {
|
||||
measurementGeneration.current += 1;
|
||||
@@ -131,37 +113,38 @@ export function AutocompletePopover({
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
|
||||
// it with the anchor. Height changes only at keyboard boundaries.
|
||||
const baseStyle = useMemo(() => {
|
||||
if (!relativeAnchorRect) return null;
|
||||
return inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: relativeAnchorRect.x,
|
||||
width: relativeAnchorRect.width,
|
||||
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||
});
|
||||
}, [relativeAnchorRect]);
|
||||
|
||||
const anchorY = relativeAnchorRect?.y ?? 0;
|
||||
const baseBottom = relativeAnchorRect
|
||||
? relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR
|
||||
: 0;
|
||||
const keyboardLayoutStyle = useAnimatedStyle(() => {
|
||||
const shiftDelta = shift.value - measuredShift.value;
|
||||
return {
|
||||
bottom: baseBottom + shiftDelta,
|
||||
maxHeight: Math.max(0, anchorY - shiftDelta - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2),
|
||||
};
|
||||
}, [anchorY, baseBottom, safeAreaInsets.top]);
|
||||
const reservedLift = anchor.reservedLift;
|
||||
const heightStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
}),
|
||||
[anchorY, reservedLift, safeAreaInsets.top],
|
||||
);
|
||||
|
||||
if (!visible || !relativeAnchorRect || !baseStyle) return null;
|
||||
if (options.length > 0 && selectedIndex < 0) return null;
|
||||
|
||||
// The translate view is the popover's own frame, so it lifts only the popover.
|
||||
return (
|
||||
<Portal hostName={portalHostName}>
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID="composer-autocomplete-popover"
|
||||
style={[baseStyle, keyboardLayoutStyle]}
|
||||
>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
@@ -172,17 +155,7 @@ export function AutocompletePopover({
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -32,9 +32,12 @@ import { useSharedValue } from "react-native-reanimated";
|
||||
export function SheetHostScope({
|
||||
children,
|
||||
style,
|
||||
enabled = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Disabled scopes keep their mounted children and use the enclosing host. */
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const parent = useBottomSheetModalInternal();
|
||||
const hostName = `sheet-host-${useId()}`;
|
||||
@@ -69,12 +72,14 @@ export function SheetHostScope({
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModalInternalProvider value={value}>
|
||||
<BottomSheetModalInternalProvider value={enabled ? value : parent}>
|
||||
<View style={[styles.scope, style]}>
|
||||
{children}
|
||||
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
|
||||
<PortalHost name={hostName} />
|
||||
</View>
|
||||
{enabled ? (
|
||||
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
|
||||
<PortalHost name={hostName} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</BottomSheetModalInternalProvider>
|
||||
);
|
||||
@@ -87,5 +92,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
host: {
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ function workspaceDescriptor(input: {
|
||||
statusEnteredAt?: Date | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
@@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: [],
|
||||
workspaceOrderByProject: {},
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SelectedFile } from "@/attachments/selected-file";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
@@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: {
|
||||
|
||||
for (const file of input.files) {
|
||||
const bytes = await file.readBytes();
|
||||
if (bytes.byteLength > 50 * 1024 * 1024) {
|
||||
if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
|
||||
throw new Error(
|
||||
i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill";
|
||||
const WORKSPACE_ID = "workspace";
|
||||
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project",
|
||||
projectDisplayName: "Project",
|
||||
|
||||
@@ -119,6 +119,20 @@ const SessionsContext = createContext<ComposerEditingSessions | null>(null);
|
||||
const WorkspaceContext = createContext<string | null>(null);
|
||||
const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null);
|
||||
|
||||
/** Keep the workspace's editing identity when a sheet renders in the app overlay host. */
|
||||
export function useComposerEditingEnvironmentBridge() {
|
||||
const sessions = useContext(SessionsContext);
|
||||
const workspaceKey = useContext(WorkspaceContext);
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<SessionsContext.Provider value={sessions}>
|
||||
<WorkspaceContext.Provider value={workspaceKey}>{children}</WorkspaceContext.Provider>
|
||||
</SessionsContext.Provider>
|
||||
),
|
||||
[sessions, workspaceKey],
|
||||
);
|
||||
}
|
||||
|
||||
/** App-container ownership survives replacement of both the gesture shell and tab layout. */
|
||||
export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) {
|
||||
const [sessions] = useState(() => new ComposerEditingSessions());
|
||||
|
||||
@@ -55,7 +55,11 @@ import {
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
@@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -995,6 +1000,7 @@ interface ComposerAutocompleteHandle {
|
||||
function ComposerAutocompleteBinding({
|
||||
text,
|
||||
cursor,
|
||||
recall,
|
||||
configuration,
|
||||
inputRef,
|
||||
anchorRef,
|
||||
@@ -1003,6 +1009,7 @@ function ComposerAutocompleteBinding({
|
||||
}: {
|
||||
text: ComposerTextSource;
|
||||
cursor: StoreApi<number>;
|
||||
recall: ComposerRecall;
|
||||
configuration: Omit<
|
||||
Parameters<typeof useAgentAutocomplete>[0],
|
||||
"userInput" | "cursorIndex" | "onAutocompleteApplied"
|
||||
@@ -1020,9 +1027,11 @@ function ComposerAutocompleteBinding({
|
||||
cursorIndex: Math.min(cursorIndex, userInput.length),
|
||||
onAutocompleteApplied: () => inputRef.current?.focus(),
|
||||
});
|
||||
// A recalled message is sent as is; its leading `/command` must not open the picker.
|
||||
const isShown = show && !recall.isShowing(userInput);
|
||||
const handleKeyPress = autocomplete.onKeyPress;
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
|
||||
show,
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
|
||||
isShown,
|
||||
handleKeyPress,
|
||||
]);
|
||||
const selectOption = autocomplete.onSelectOption;
|
||||
@@ -1032,7 +1041,7 @@ function ComposerAutocompleteBinding({
|
||||
);
|
||||
return (
|
||||
<ComposerAutocomplete
|
||||
visible={autocomplete.isVisible && show}
|
||||
visible={autocomplete.isVisible && isShown}
|
||||
anchorRef={anchorRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
@@ -1362,6 +1371,7 @@ function ComposerContentImpl({
|
||||
);
|
||||
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
|
||||
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
|
||||
const recall = useMemo(() => new ComposerRecall(), []);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
|
||||
const nextPendingFileId = useRef(0);
|
||||
@@ -1981,10 +1991,19 @@ function ComposerContentImpl({
|
||||
|
||||
const hasSendableContent = hasText || selectedAttachments.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete.
|
||||
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
|
||||
[],
|
||||
(event: ComposerKeyPressEvent) => {
|
||||
if (autocompleteRef.current?.onKeyPress(event)) return true;
|
||||
const recalledText = recall.handleKeyPress(event, () =>
|
||||
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
|
||||
);
|
||||
if (recalledText === null) return false;
|
||||
event.preventDefault();
|
||||
replaceUserInput(recalledText);
|
||||
return true;
|
||||
},
|
||||
[agentId, recall, replaceUserInput, serverId],
|
||||
);
|
||||
|
||||
const cancelButtonStyle = useMemo(
|
||||
@@ -2423,6 +2442,7 @@ function ComposerContentImpl({
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
recall={recall}
|
||||
inputRef={messageInputRef}
|
||||
anchorRef={messageInputContainerRef}
|
||||
show={mode.showAutocomplete && !isFullscreen}
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface ComposerInputSnapshot {
|
||||
|
||||
export interface ComposerKeyPressEvent {
|
||||
key: string;
|
||||
/** Shift, Alt, Ctrl, or Meta was held. */
|
||||
hasModifier: boolean;
|
||||
preventDefault: () => void;
|
||||
input: ComposerInputSnapshot;
|
||||
}
|
||||
@@ -210,6 +212,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
@@ -411,17 +414,18 @@ function handleDesktopKeyPressImpl(
|
||||
): void {
|
||||
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
|
||||
|
||||
if (ctx.onKeyPressCallback) {
|
||||
const handled = ctx.onKeyPressCallback({
|
||||
key: event.nativeEvent.key,
|
||||
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
|
||||
preventDefault: () => event.preventDefault(),
|
||||
input: ctx.input,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
|
||||
const SENT = ["add tests", "fix the auth bug"];
|
||||
|
||||
function press(
|
||||
key: string,
|
||||
text: string,
|
||||
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
|
||||
): ComposerKeyPressEvent {
|
||||
const caret = options.caret ?? text.length;
|
||||
return {
|
||||
key,
|
||||
hasModifier: options.hasModifier ?? false,
|
||||
preventDefault: () => undefined,
|
||||
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
|
||||
function composerWithRecall(sent: readonly string[] = SENT) {
|
||||
const recall = new ComposerRecall();
|
||||
let text = "";
|
||||
return {
|
||||
recall,
|
||||
get text() {
|
||||
return text;
|
||||
},
|
||||
type(next: string) {
|
||||
text = next;
|
||||
},
|
||||
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
|
||||
const result = recall.handleKeyPress(press(key, text, options), () => sent);
|
||||
if (result !== null) text = result;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComposerRecall", () => {
|
||||
it("walks back through sent messages and forward to an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp")).toBe("add tests");
|
||||
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
|
||||
expect(composer.press("ArrowDown")).toBe("add tests");
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the caret to the input past the oldest message", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.text).toBe("fix the auth bug");
|
||||
});
|
||||
|
||||
it("only starts from an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.type("half written");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores Down from an empty composer and Up with no history", () => {
|
||||
expect(composerWithRecall().press("ArrowDown")).toBeNull();
|
||||
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the arrows back to the caret once the recalled message is edited", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.type("add tests for login");
|
||||
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves between lines of a multi-line message before changing message", () => {
|
||||
const composer = composerWithRecall(["first line\nsecond line", "older"]);
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
|
||||
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
|
||||
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
|
||||
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores modified arrows, other keys, and a selection", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
|
||||
expect(composer.press("Enter")).toBeNull();
|
||||
composer.press("ArrowUp");
|
||||
const recall = composer.recall;
|
||||
expect(
|
||||
recall.handleKeyPress(
|
||||
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
|
||||
() => SENT,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user