mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 08:42:39 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fd36e2e9 |
+1
-64
@@ -1,93 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger))
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174))
|
||||
- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190))
|
||||
- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166))
|
||||
- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119))
|
||||
- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161))
|
||||
- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168))
|
||||
- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g))
|
||||
- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170))
|
||||
- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171))
|
||||
|
||||
## 0.7.1 - 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
|
||||
|
||||
## 0.7.0 - 2026-08-31
|
||||
## 0.7.0-beta.1 - 2026-08-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
|
||||
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
|
||||
|
||||
### Added
|
||||
|
||||
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
||||
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
|
||||
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
|
||||
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
|
||||
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
|
||||
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
|
||||
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
|
||||
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
|
||||
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
|
||||
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
|
||||
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
|
||||
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
|
||||
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
|
||||
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
|
||||
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
|
||||
|
||||
### Improved
|
||||
|
||||
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
|
||||
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
|
||||
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
|
||||
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
|
||||
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
|
||||
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
|
||||
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
|
||||
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
|
||||
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
|
||||
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
|
||||
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
|
||||
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
|
||||
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
|
||||
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
|
||||
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
|
||||
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
|
||||
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
|
||||
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
|
||||
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
|
||||
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
|
||||
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
|
||||
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
|
||||
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
|
||||
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
|
||||
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
||||
|
||||
@@ -46,7 +46,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [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 |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
|
||||
@@ -111,8 +111,8 @@ paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
paseo send abc123 "also add tests" # follow-up task
|
||||
|
||||
# run on a remote daemon; --cwd is a path on that host
|
||||
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
|
||||
# run on a remote daemon
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
@@ -31,15 +31,6 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
||||
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
|
||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||||
silently stops.
|
||||
|
||||
## Measuring
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
|
||||
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
|
||||
@@ -393,7 +393,6 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
},
|
||||
"params": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"rpcTimeoutMs": 60000,
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
"slowModel": "anthropic/claude-opus-4-1",
|
||||
"planModel": "openai/o3"
|
||||
@@ -404,7 +403,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
@@ -417,8 +416,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions",
|
||||
"rpcTimeoutMs": 60000
|
||||
"sessionDir": "~/.my-pi-fork/sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,7 +424,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
}
|
||||
```
|
||||
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
|
||||
+2
-5
@@ -556,15 +556,12 @@ npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use `--host` to point the CLI at a different daemon:
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a --host localhost:7777
|
||||
npm run cli -- ls -a --host ssh://user@host
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||
|
||||
+21
-12
@@ -132,21 +132,30 @@ quietly relying on:
|
||||
|
||||
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
|
||||
Move the chat surface with worklet-driven bottom padding from
|
||||
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
|
||||
and native hit testing in the same layout. Do not translate the stream and
|
||||
composer independently.
|
||||
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||
includes Reanimated-applied transforms (Reanimated updates native view
|
||||
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||
with Reanimated worklets the result is not always stable.
|
||||
|
||||
Do not use the controller's raw keyboard progress for the padding. It can retain
|
||||
a nonzero value after the keyboard closes. The shared provider normalizes that
|
||||
state and reconciles native animation-end events.
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
|
||||
the composer uses**:
|
||||
|
||||
`measureInWindow` already includes the dock's current padding layout. When a
|
||||
portal opens, snapshot the current shift and apply only the subsequent shift
|
||||
delta to its animated `bottom`. Adding the full shift moves the portal twice and
|
||||
can place it over the composer controls.
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
to the popover wrapper.
|
||||
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed. Do not call
|
||||
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
|
||||
can briefly report a stale nonzero height with closed progress, and the shared
|
||||
provider is where that is normalized.
|
||||
|
||||
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
|
||||
|
||||
+8
-12
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -17,21 +17,17 @@ The daemon persists a relationship ID and private connection credential before e
|
||||
relationship is independent of its current transport, so a future transport can replace the direct
|
||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||
|
||||
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
|
||||
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
|
||||
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
|
||||
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
|
||||
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
|
||||
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
|
||||
manage their own relationship or permissions.
|
||||
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
||||
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
||||
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
||||
|
||||
## Session grants and execution ownership
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||
connection receives its persisted permissions. Legacy relationship files carrying
|
||||
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
||||
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
|
||||
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
|
||||
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
|
||||
shape.
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
|
||||
+10
-21
@@ -30,10 +30,8 @@ The UI worklet owns transient motion:
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility.
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
|
||||
## Why one position
|
||||
|
||||
@@ -57,10 +55,8 @@ while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
||||
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
||||
the same target.
|
||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||
its target and revision still match the canonical command.
|
||||
|
||||
Manual gesture arbitration has two phases:
|
||||
|
||||
@@ -89,14 +85,8 @@ definition, no longer eligible to begin.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
|
||||
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
|
||||
animated props back to React through a collector that forgets entries older than two seconds. A JS
|
||||
stall across a panel settle loses the final position, and the next React commit reverts the overlay
|
||||
to its last synced props: a backdrop over the workspace while the store says closed. Upstream fixed
|
||||
the collector in 4.4.3, which needs React Native 0.83.
|
||||
- Gesture start and progress must not update React state. The retained overlay is already mounted and
|
||||
offscreen; only the shared position and its derived native styles move during a drag.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
@@ -108,9 +98,8 @@ definition, no longer eligible to begin.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
|
||||
canonical target's anchor is the settlement signal; animation duration and completion callbacks
|
||||
do not own activity. Cancellation publishes nothing.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
@@ -118,5 +107,5 @@ definition, no longer eligible to begin.
|
||||
## Tests
|
||||
|
||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
|
||||
a sequence there whenever ownership or ordering changes.
|
||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||
sequence there whenever ownership or ordering changes.
|
||||
|
||||
@@ -259,9 +259,7 @@ background music stays paused. On iOS this is not just a "while recording" probl
|
||||
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
|
||||
it every time the app returns to the foreground — so one dictation turn kills music for the life of
|
||||
the process, on every open, until the app is force-quit. Android holds
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
|
||||
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
|
||||
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
|
||||
|
||||
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
|
||||
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
|
||||
@@ -269,9 +267,9 @@ also releases on `OnAppEntersBackground`. The native side re-guards on `isRecord
|
||||
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
|
||||
wrappers (voice provider + dictation) and only it knows the true state.
|
||||
|
||||
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
|
||||
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
|
||||
background/foreground the app and confirm it keeps playing at full quality.
|
||||
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
|
||||
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
|
||||
playing.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Daemon permissions
|
||||
|
||||
The daemon authorizes principals with semantic permissions. RPC names and protocol namespaces are not authority.
|
||||
|
||||
## Model
|
||||
|
||||
```text
|
||||
principal -> grants
|
||||
|
|
||||
+-- authenticated by a device or service credential
|
||||
`-- opens a session with equal or narrower authority
|
||||
```
|
||||
|
||||
A principal is the durable identity the daemon authorizes. A credential proves that a device or service represents it. Keep them separate so you can rotate credentials, attach more than one device, and revoke a Hub user without inventing daemon user accounts.
|
||||
|
||||
A pairing invitation is neither. It is an expiring, single-use exchange that creates a principal and credential with the permissions selected by its issuer.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Authority |
|
||||
| ------------------- | -------------------------------------------------------------------------- |
|
||||
| `daemon.read` | Daemon status, diagnostics, configuration, and provider information |
|
||||
| `daemon.manage` | Restart, update, configuration changes, providers, skills, and plugins |
|
||||
| `tunnel.manage` | Relay, Hub, service tunnel, and public endpoint relationships |
|
||||
| `access.manage` | Pairing invitations, principals, credentials, grants, and revocation |
|
||||
| `workspace.read` | Projects, workspaces, agents, timelines, files, diffs, and terminal output |
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | The Hub-owned execution lifecycle |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
## Resources
|
||||
|
||||
Permissions are daemon-wide today. Future grants may select workspaces or agents, but operation classification remains inside the authorization module:
|
||||
|
||||
```ts
|
||||
type Grant = {
|
||||
permission: Permission;
|
||||
resource: { kind: "daemon" } | { kind: "workspace"; ids: string[] };
|
||||
};
|
||||
```
|
||||
|
||||
A delegating principal can grant only authority it already possesses. A session may attenuate its principal's grants but cannot widen them.
|
||||
|
||||
Workspace-scoped grants require every resource-bearing operation and outbound observation to enforce the same workspace boundary. File preview currently accepts any daemon-readable regular file, so it must gain resource enforcement before workspace-specific access ships.
|
||||
|
||||
## Hub
|
||||
|
||||
The Hub authenticates as a service principal. Its locally selected grants decide whether it may execute agents, manage the daemon, manage tunnels, or manage access.
|
||||
|
||||
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
|
||||
|
||||
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
|
||||
+18
-42
@@ -2,9 +2,8 @@
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
Paseo does not sandbox it.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -48,13 +47,15 @@ my-plugin/
|
||||
paseo-plugin.json
|
||||
index.ts
|
||||
main.client.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
||||
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
|
||||
runtime modules, so consumers do not install these packages when adding the plugin.
|
||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -83,52 +84,28 @@ directory always wins over shorthand resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/monorepo --path plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin status
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
||||
it before changing the configured path. A compilation or startup failure restores the running
|
||||
version. Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
that Paseo does not provide, generate source or assets, or perform another required preparation
|
||||
step:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`build` is an optional list of argv arrays. Each array must contain at least one non-empty string;
|
||||
shell command strings are rejected. Paseo starts the executable directly, without a shell, from the
|
||||
plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.
|
||||
|
||||
On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared
|
||||
commands, then validates, compiles, and activates the candidate. It logs each argv command and its
|
||||
output in the daemon log. If a command fails, the error includes its output, Paseo discards the
|
||||
candidate, and the existing installed and running version stays untouched. On a remote daemon, all
|
||||
of this happens on the remote daemon host.
|
||||
Git installation does not run a package manager or install script. A distributable source plugin
|
||||
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
||||
dependencies work only when the Paseo app already ships the native module.
|
||||
|
||||
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Git preparation commands are
|
||||
recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
||||
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
||||
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||
log credentials or tokens.
|
||||
@@ -224,10 +201,9 @@ when moved between hosts. Explorer configuration can create workspace-context pa
|
||||
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
|
||||
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
||||
workspace-layout store access.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
|
||||
+3
-7
@@ -67,8 +67,6 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
@@ -77,17 +75,15 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,11 +0,0 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-j3lk7o4W10hcXeNIN3kGlqSHn8aCr+GCDkKYZNXNSrw=
|
||||
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36298,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37363,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/server": "0.7.2",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/server": "0.7.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37615,10 +37615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37629,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37872,7 +37872,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38797,13 +38797,13 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38812,15 +38812,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38833,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39051,17 +39051,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/highlight": "0.7.2",
|
||||
"@getpaseo/plugin": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/highlight": "0.7.0-beta.1",
|
||||
"@getpaseo/plugin": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39985,7 +39985,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -41,7 +41,6 @@
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
|
||||
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
|
||||
@@ -23,7 +23,7 @@ async function completeSubmittedTurn(
|
||||
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ async function expectTurnCompletesNormally(
|
||||
): Promise<void> {
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,7 @@ import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -471,10 +468,6 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
switchAwayAndBackWithoutImageInstability,
|
||||
userPagesUntilAssistantImageRenders,
|
||||
} from "../support/helpers/assistant-images";
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
@@ -46,56 +45,6 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
|
||||
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
|
||||
});
|
||||
|
||||
test("opens a timeline image in a zoomable lightbox", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Zoomable timeline image",
|
||||
fileName: "zoomable-timeline-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Zoomable image timeline");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
await page.getByRole("button", { name: "Open image attachment" }).click();
|
||||
const lightboxImage = page.getByTestId("attachment-lightbox-image");
|
||||
const canvas = page.getByTestId("attachment-lightbox-canvas");
|
||||
await expect(lightboxImage).toBeVisible();
|
||||
await canvas.hover();
|
||||
const transformedContent = canvas.locator(":scope > div").first();
|
||||
const initialBox = await transformedContent.boundingBox();
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
expect(initialBox).not.toBeNull();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
expect(initialBox!.width).toBeLessThan(canvasBox!.width);
|
||||
const zoomIn = page.getByRole("button", { name: "Zoom in", exact: true });
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await expect
|
||||
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(canvasBox!.width);
|
||||
|
||||
await canvas.dblclick({ position: { x: canvasBox!.width / 2, y: canvasBox!.height / 2 } });
|
||||
await expect
|
||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||
.toBe(Math.round(initialBox!.width));
|
||||
|
||||
await workspace.client.sendAgentMessage(
|
||||
imageAgent.id,
|
||||
"Stay running while the assistant image lightbox is closed.",
|
||||
);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(lightboxImage).toHaveCount(0);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
@@ -146,8 +144,6 @@ test.describe("Archive tab reconciliation", () => {
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-message-render-limit-",
|
||||
title: "Assistant message render limit",
|
||||
initialPrompt: "Render the configured oversized response.",
|
||||
featureValues: { mockAssistantResponse: response },
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(assistantMessage).not.toContainText(hiddenTail);
|
||||
|
||||
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toHaveText(
|
||||
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
|
||||
);
|
||||
await expect(notice).toHaveCSS("font-style", "italic");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -37,10 +37,6 @@ function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||
}
|
||||
|
||||
function diffHeaderForPath(panel: Locator, filePath: string): Locator {
|
||||
return panel.locator(`[data-diff-header-path="${filePath}"]`).getByTestId(/^diff-file-\d+$/);
|
||||
}
|
||||
|
||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
@@ -265,12 +261,12 @@ test.afterEach(async () => {
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
@@ -306,7 +302,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
@@ -316,7 +312,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
@@ -326,8 +322,9 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
@@ -381,109 +378,49 @@ test("every interactive file header has the same hover feedback", async ({ page
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
const normalBackground = await first.evaluate(
|
||||
(element) => getComputedStyle(element.parentElement!).backgroundColor,
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
||||
)
|
||||
.toBe("0px");
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
const hoverBackground = await first.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(hoverBackground).not.toBe(normalBackground);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
||||
)
|
||||
.toBe(normalBackground);
|
||||
const [sharedBorder, secondTopBorderWidth] = await Promise.all([
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderBottomColor),
|
||||
second.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
||||
]);
|
||||
expect(sharedBorder).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(secondTopBorderWidth).toBe("0px");
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-header-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforeImage = await headerCanvas.evaluate((element) =>
|
||||
(element as HTMLCanvasElement).toDataURL(),
|
||||
);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
|
||||
beforeImage,
|
||||
);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return [14, 16].map(
|
||||
(y) =>
|
||||
document
|
||||
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
|
||||
);
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
||||
)
|
||||
.toBe(normalBackground);
|
||||
await expect
|
||||
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
@@ -538,8 +475,7 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
||||
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
|
||||
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
@@ -549,17 +485,9 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(diffNames).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
await expect(diffNames).toHaveText(expected);
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
@@ -658,10 +586,9 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const renamedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-renamed.ts" });
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
@@ -688,10 +615,9 @@ test("discarding an untracked file removes it from the working tree", async ({ p
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
|
||||
await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0");
|
||||
const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const untrackedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-untracked.txt" });
|
||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
@@ -750,12 +676,8 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
@@ -790,18 +712,16 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
@@ -1060,29 +980,6 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1483,58 +1380,6 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
),
|
||||
);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create many-file workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace> {
|
||||
const source = Array.from(
|
||||
{ length: 48 },
|
||||
(_, index) => `export const value${index} = ${index};`,
|
||||
).join("\n");
|
||||
const files = ["src/first.ts", "src/second.ts"].map((filePath) => ({
|
||||
path: filePath,
|
||||
content: `${source}\n`,
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-sticky-transition-", { files });
|
||||
const changedSource = Array.from(
|
||||
{ length: 48 },
|
||||
(_, index) => `export const value${index} = ${index + 100};`,
|
||||
).join("\n");
|
||||
await Promise.all(
|
||||
files.map((file) => writeFile(path.join(repo.path, file.path), `${changedSource}\n`)),
|
||||
);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create sticky workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
@@ -1544,15 +1389,11 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openWorkspaceChangesSurface(
|
||||
page: Page,
|
||||
workspace: DirtyWorkspace,
|
||||
timeout?: number,
|
||||
): Promise<void> {
|
||||
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page, timeout);
|
||||
await openChangesPanel(page);
|
||||
}
|
||||
|
||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -1561,7 +1402,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, {
|
||||
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
@@ -1860,30 +1701,6 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function headerCanvasPixel(
|
||||
canvas: Locator,
|
||||
header: Locator,
|
||||
offsetY: number,
|
||||
): Promise<string> {
|
||||
const [canvasBounds, headerBounds] = await Promise.all([
|
||||
canvas.boundingBox(),
|
||||
header.boundingBox(),
|
||||
]);
|
||||
if (!canvasBounds || !headerBounds) throw new Error("Canvas header has no bounds");
|
||||
return canvas.evaluate(
|
||||
(element, point) => {
|
||||
const target = element as HTMLCanvasElement;
|
||||
const ratio = target.width / target.getBoundingClientRect().width;
|
||||
const context = target.getContext("2d");
|
||||
if (!context) throw new Error("Canvas header has no 2D context");
|
||||
return Array.from(
|
||||
context.getImageData(Math.round(point.x * ratio), Math.round(point.y * ratio), 1, 1).data,
|
||||
).join(",");
|
||||
},
|
||||
{ x: 4, y: headerBounds.y - canvasBounds.y + offsetY },
|
||||
);
|
||||
}
|
||||
|
||||
async function readDiffTypographyGeometry(page: Page): Promise<{
|
||||
horizontalExtent: number;
|
||||
canvasPixels: string;
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
|
||||
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
|
||||
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string): Locator {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
function action(panel: Locator, title: string): Locator {
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = workspaceRow(page, workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
|
||||
// that used to make the sidebar-owned rename dialog unreachable.
|
||||
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: project.projectDisplayName });
|
||||
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
|
||||
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
|
||||
// surface under test anyway, and it works from every route.
|
||||
async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, query: string, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(query);
|
||||
const entry = action(panel, title);
|
||||
await expect(entry).toBeVisible({ timeout: 15_000 });
|
||||
await entry.click();
|
||||
}
|
||||
|
||||
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
|
||||
const workspaces = await workspace.client.fetchWorkspaces();
|
||||
for (const entry of workspaces.entries) {
|
||||
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||
let rejectNext = true;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
let request: { type?: string; requestId?: string } | null = null;
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
message?: { type?: string; requestId?: string };
|
||||
};
|
||||
request = envelope.type === "session" ? (envelope.message ?? null) : null;
|
||||
} catch {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
rejectNext &&
|
||||
request?.type === "workspace.label.assignment.set.request" &&
|
||||
request.requestId
|
||||
) {
|
||||
rejectNext = false;
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestType: request.type,
|
||||
error: "Injected label mutation failure.",
|
||||
code: "handler_error",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Command center workspace management", () => {
|
||||
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
|
||||
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||
|
||||
try {
|
||||
await workspace.client.setWorkspaceLabel({
|
||||
workspaceId: workspace.workspaceId,
|
||||
label: { name: "Urgent", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("urgent");
|
||||
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
// Unassigning the only label drops the field entirely rather than leaving an empty array.
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
|
||||
|
||||
const reopened = await openCommandCenter(page);
|
||||
await reopened.getByTestId("command-center-input").fill("urgent");
|
||||
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an error toast when assigning a label fails", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
|
||||
|
||||
try {
|
||||
await rejectNextLabelAssignment(page);
|
||||
await workspace.client.setWorkspaceLabel({
|
||||
workspaceId: workspace.workspaceId,
|
||||
label: { name: "Urgent", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("urgent");
|
||||
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(
|
||||
"Injected label mutation failure.",
|
||||
);
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -103,15 +103,23 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||
const [header, canvas] = await Promise.all([
|
||||
const [header, content, name, stat] = await Promise.all([
|
||||
panel.getByTestId("diff-file-0").boundingBox(),
|
||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-header-content").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-name").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(canvas).not.toBeNull();
|
||||
expect(content).not.toBeNull();
|
||||
expect(name).not.toBeNull();
|
||||
expect(stat).not.toBeNull();
|
||||
expect(header!.height).toBeCloseTo(30, 0);
|
||||
expect(header!.x).toBeCloseTo(canvas!.x, 0);
|
||||
expect(header!.width).toBeCloseTo(canvas!.width, 0);
|
||||
expect(header!.y).toBeCloseTo(canvas!.y, 0);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
|
||||
expect(content!.x).toBeCloseTo(header!.x, 0);
|
||||
expect(content!.width).toBeCloseTo(header!.width, 0);
|
||||
expect(name!.x - header!.x).toBeCloseTo(12, 0);
|
||||
expect(name!.x + name!.width).toBeLessThan(stat!.x);
|
||||
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-",
|
||||
title: "Composer focus",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -10,10 +10,8 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
|
||||
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
||||
|
||||
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
||||
const RUN_HEADER_COMPARISON = process.env.PASEO_DIFF_HEADER_COMPARISON === "1";
|
||||
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
||||
const LINE_COUNT_PER_FILE = 1_200;
|
||||
const MANY_FILE_COUNT = Number(process.env.PASEO_DIFF_MANY_FILE_COUNT ?? 2_000);
|
||||
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
|
||||
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
@@ -23,10 +21,8 @@ const POC_MEDIAN_FRAME_MS = Number(
|
||||
const POC_P95_FRAME_MS = Number(
|
||||
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
||||
);
|
||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
|
||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 2.4;
|
||||
const MANY_FILE_MAX_MEDIAN_FRAME_MS = 45;
|
||||
const MANY_FILE_MAX_P95_FRAME_MS = 80;
|
||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
|
||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
||||
|
||||
diffPerfDescribe("Diff canvas performance", () => {
|
||||
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
||||
@@ -62,131 +58,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("many small files keep canvas headers bounded and responsive", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(300_000);
|
||||
const repo = await createManyFileDiffRepository(MANY_FILE_COUNT);
|
||||
const client = await connectSeedClient();
|
||||
let cdp: CDPSession | null = null;
|
||||
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to create many-file performance workspace");
|
||||
}
|
||||
|
||||
await configureUnwrappedUnifiedDiff(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
const openedAt = performance.now();
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page, 90_000);
|
||||
await expect(page.getByTestId("diff-file-0-toggle")).toBeVisible({ timeout: 30_000 });
|
||||
const readyMs = performance.now() - openedAt;
|
||||
const [initialShells, initialElements, paintKind] = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
.evaluate(
|
||||
(root) =>
|
||||
[
|
||||
root.querySelectorAll('[data-diff-header="true"]').length,
|
||||
root.querySelectorAll("*").length,
|
||||
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
|
||||
] as const,
|
||||
);
|
||||
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
|
||||
const style = getComputedStyle(header);
|
||||
const bounds = header.getBoundingClientRect();
|
||||
const leaves = [];
|
||||
for (const element of header.querySelectorAll<HTMLElement>("*")) {
|
||||
if (element.children.length !== 0 || !element.textContent?.trim()) continue;
|
||||
const leafStyle = getComputedStyle(element);
|
||||
const leafBounds = element.getBoundingClientRect();
|
||||
leaves.push({
|
||||
text: element.textContent,
|
||||
x: leafBounds.x - bounds.x,
|
||||
y: leafBounds.y - bounds.y,
|
||||
width: leafBounds.width,
|
||||
height: leafBounds.height,
|
||||
font: leafStyle.font,
|
||||
lineHeight: leafStyle.lineHeight,
|
||||
color: leafStyle.color,
|
||||
});
|
||||
}
|
||||
return {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
padding: style.padding,
|
||||
border: style.border,
|
||||
boxShadow: style.boxShadow,
|
||||
leaves,
|
||||
};
|
||||
});
|
||||
const headerCanvasMetrics = await page
|
||||
.getByTestId("git-diff-header-canvas")
|
||||
.evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Header canvas has no 2D context");
|
||||
const font = context.font;
|
||||
return {
|
||||
font,
|
||||
nameWidth: context.measureText("file-0000.ts").width,
|
||||
directoryWidth: context.measureText(" src").width,
|
||||
};
|
||||
})
|
||||
.catch(() => null);
|
||||
const capturePath = process.env.PASEO_DIFF_HEADER_CAPTURE_PATH;
|
||||
if (capturePath) {
|
||||
await writeFile(capturePath, await page.getByTestId("git-diff-canvas-root").screenshot());
|
||||
}
|
||||
|
||||
cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||
await startRendererInstrumentation(page);
|
||||
const benchmark = await benchmarkManyFileHeaderScrolling(page);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
const heap = await collectHeapUsage(cdp);
|
||||
|
||||
await attachPerformanceReport(testInfo, {
|
||||
scenario: "many-small-files",
|
||||
sourceFiles: MANY_FILE_COUNT,
|
||||
cpuSlowdown: CPU_SLOWDOWN,
|
||||
readyMs,
|
||||
initialShells,
|
||||
initialElements,
|
||||
paintKind,
|
||||
headerDomMetrics,
|
||||
headerCanvasMetrics,
|
||||
benchmark,
|
||||
renderer,
|
||||
heap,
|
||||
});
|
||||
console.log(
|
||||
`[perf] Many-file headers: ${JSON.stringify({ readyMs, initialShells, initialElements, paintKind, headerDomMetrics, headerCanvasMetrics, benchmark, renderer, heap })}`,
|
||||
);
|
||||
if (!RUN_HEADER_COMPARISON) {
|
||||
expect(benchmark.maximumShells).toBeLessThan(120);
|
||||
expect(benchmark.blankHeaderFrames).toBe(0);
|
||||
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
|
||||
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
|
||||
expect(Math.abs(benchmark.maximumScrollTop - benchmark.endScrollTop)).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(80);
|
||||
}
|
||||
} finally {
|
||||
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
||||
await cdp?.detach().catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("bounded canvases stay filled through continuous fast scrolling", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createLargeDiffRepository();
|
||||
const client = await connectSeedClient();
|
||||
@@ -208,7 +80,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectBoundedCanvasSurfaces(page);
|
||||
await expectSingleCanvasSurface(page);
|
||||
cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||
await startChromiumTrace(cdp);
|
||||
@@ -238,9 +110,6 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
const measuredCycles = scrollReports.slice(1);
|
||||
console.log(
|
||||
`[perf] Large-file canvas: ${JSON.stringify({ scrollReports, heapUsage, renderer })}`,
|
||||
);
|
||||
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
||||
MAX_MEDIAN_FRAME_MS,
|
||||
);
|
||||
@@ -253,9 +122,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
||||
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
||||
);
|
||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||
expect(renderer.addedElements).toBe(0);
|
||||
expect(renderer.removedElements).toBe(0);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||
|
||||
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
||||
@@ -300,7 +169,7 @@ async function measureAdditionalPerformanceScenario(
|
||||
testInfo: TestInfo,
|
||||
name: "wrapped" | "split",
|
||||
) {
|
||||
await expectBoundedCanvasSurfaces(page);
|
||||
await expectSingleCanvasSurface(page);
|
||||
await startRendererInstrumentation(page);
|
||||
const scrollReports = [];
|
||||
const heapUsage = [];
|
||||
@@ -333,9 +202,9 @@ async function measureAdditionalPerformanceScenario(
|
||||
}
|
||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||
expect(renderer.addedElements).toBe(0);
|
||||
expect(renderer.removedElements).toBe(0);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||
return {
|
||||
scrollReports,
|
||||
heapUsage,
|
||||
@@ -382,20 +251,6 @@ async function createLargeDiffRepository() {
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function createManyFileDiffRepository(fileCount: number) {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("diff-perf-many-files-", { files });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
),
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
@@ -434,8 +289,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||
return performance.now() - startedAt;
|
||||
}
|
||||
|
||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(2);
|
||||
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
const elementCount = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
@@ -522,92 +377,6 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
medianFrameMs: number;
|
||||
p95FrameMs: number;
|
||||
maximumFrameMs: number;
|
||||
maximumShells: number;
|
||||
blankHeaderFrames: number;
|
||||
endScrollTop: number;
|
||||
maximumScrollTop: number;
|
||||
}> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||
'[data-testid="git-diff-header-canvas"]',
|
||||
);
|
||||
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||
const frameDurations: number[] = [];
|
||||
let maximumShells = 0;
|
||||
let blankHeaderFrames = 0;
|
||||
const sampleHeader = () => {
|
||||
maximumShells = Math.max(
|
||||
maximumShells,
|
||||
document.querySelectorAll('[data-diff-header="true"]').length,
|
||||
);
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d")!;
|
||||
const ratio = canvas.width / canvas.getBoundingClientRect().width;
|
||||
if (
|
||||
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
if (
|
||||
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const sweepStart = Math.min(
|
||||
maximumScrollTop,
|
||||
Math.max(0, maximumScrollTop * progress - scroll.clientHeight),
|
||||
);
|
||||
scroll.scrollTop = sweepStart;
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
let previousFrame = performance.now();
|
||||
for (let step = 1; step <= 24; step += 1) {
|
||||
scroll.scrollTop = Math.min(
|
||||
maximumScrollTop,
|
||||
sweepStart + (scroll.clientHeight * step) / 24,
|
||||
);
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const now = performance.now();
|
||||
frameDurations.push(now - previousFrame);
|
||||
previousFrame = now;
|
||||
sampleHeader();
|
||||
}
|
||||
}
|
||||
|
||||
scroll.scrollTop = maximumScrollTop;
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
sampleHeader();
|
||||
|
||||
const ordered = [...frameDurations].sort((left, right) => left - right);
|
||||
const percentile = (fraction: number) =>
|
||||
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
|
||||
return {
|
||||
medianFrameMs: percentile(0.5),
|
||||
p95FrameMs: percentile(0.95),
|
||||
maximumFrameMs: Math.max(...frameDurations),
|
||||
maximumShells,
|
||||
blankHeaderFrames,
|
||||
endScrollTop: scroll.scrollTop,
|
||||
maximumScrollTop,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedCanvasFrame {
|
||||
scrollTop: number;
|
||||
image: Buffer;
|
||||
@@ -685,33 +454,21 @@ async function expectContinuousFramesMatchStableRender(
|
||||
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
||||
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
||||
await setVerticalOffset(scroller, 0);
|
||||
await expect(first).toBeAttached();
|
||||
const firstOffset = await setHorizontalOffset(first, 40);
|
||||
await setVerticalOffset(scroller, "end");
|
||||
await expect(second).toBeAttached();
|
||||
const secondOffset = await setHorizontalOffset(second, 20);
|
||||
const firstOffset = await setHorizontalOffset(first, 240);
|
||||
const secondOffset = await setHorizontalOffset(second, 120);
|
||||
expect(firstOffset).not.toBe(secondOffset);
|
||||
await setVerticalOffset(scroller, 0);
|
||||
await expect(first).toBeAttached();
|
||||
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
||||
await setVerticalOffset(scroller, "end");
|
||||
await expect(second).toBeAttached();
|
||||
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
||||
}
|
||||
|
||||
async function setVerticalOffset(locator: Locator, offset: number | "end") {
|
||||
await locator.evaluate(async (element, nextOffset) => {
|
||||
element.scrollTop =
|
||||
nextOffset === "end" ? element.scrollHeight - element.clientHeight : nextOffset;
|
||||
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
}, offset);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
||||
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
||||
}
|
||||
|
||||
async function setHorizontalOffset(locator: Locator, offset: number) {
|
||||
|
||||
@@ -538,16 +538,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await imageCanvas.hover();
|
||||
const transformedContent = imageCanvas.locator(":scope > div").first();
|
||||
const fittedImageBox = await transformedContent.boundingBox();
|
||||
expect(fittedImageBox).not.toBeNull();
|
||||
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(fittedImageBox!.width * 1.2);
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
|
||||
const GREETING = "Say hello back.";
|
||||
|
||||
test.use({ e2eInjectPaseoTools: true });
|
||||
|
||||
function verificationPrompt(workspacePath: string): string {
|
||||
return [
|
||||
"Use Paseo tools to perform this exact UI verification.",
|
||||
"Do not edit files or run shell commands. Every prompt below is intentionally non-actionable.",
|
||||
`1. Create a local workspace at ${workspacePath} titled "Presentation QA workspace".`,
|
||||
`2. In that workspace, create a codex/gpt-5.4-mini agent titled "Greeting child" with the initial prompt "${GREETING}".`,
|
||||
`3. Create a schedule named "Greeting schedule" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", provider "codex/gpt-5.4-mini", cwd ${workspacePath}, maxRuns 1, and expiresIn "1h". Delete that schedule using its returned id.`,
|
||||
`4. Create a heartbeat named "Greeting heartbeat" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", maxRuns 1, and expiresIn "1h". Delete that heartbeat using its returned id.`,
|
||||
`5. After the child agent finishes, send it the follow-up prompt "${GREETING}" synchronously.`,
|
||||
"6. Archive the workspace from step 1.",
|
||||
"7. Reply with exactly TOOL_CALL_QA_DONE.",
|
||||
"Use each named Paseo tool exactly once except for the matching schedule and heartbeat deletion tools.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function expandedToolCall(page: Page, label: string): Promise<Locator> {
|
||||
const badge = page.getByTestId("tool-call-badge").filter({ hasText: label }).last();
|
||||
await expect(badge).toBeVisible({ timeout: 30_000 });
|
||||
await badge.scrollIntoViewIfNeeded();
|
||||
await badge.click();
|
||||
return badge;
|
||||
}
|
||||
|
||||
async function captureToolCall(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
input: { label: string; artifact: string; expectedText: Array<string | RegExp> },
|
||||
): Promise<void> {
|
||||
const badge = await expandedToolCall(page, input.label);
|
||||
for (const text of input.expectedText) {
|
||||
await expect(badge).toContainText(text);
|
||||
}
|
||||
const screenshot = testInfo.outputPath(`${input.artifact}.png`);
|
||||
await badge.screenshot({ path: screenshot });
|
||||
await testInfo.attach(input.artifact, { path: screenshot, contentType: "image/png" });
|
||||
await badge.click();
|
||||
}
|
||||
|
||||
async function runPresentationJourney(handle: AgentHandle, workspacePath: string): Promise<void> {
|
||||
await sendMessage(handle, verificationPrompt(workspacePath));
|
||||
await expect(
|
||||
handle.page.getByTestId("assistant-message").filter({ hasText: "TOOL_CALL_QA_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("renders owned tool inputs and results as readable text", async ({ page }, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-tool-call-presentation-")));
|
||||
const childWorkspacePath = path.join(cwd, "presentation-workspace");
|
||||
mkdirSync(childWorkspacePath);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
|
||||
await runPresentationJourney(handle, childWorkspacePath);
|
||||
await page.setViewportSize({ width: 1280, height: 1600 });
|
||||
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create workspace",
|
||||
artifact: "create-workspace-presentation",
|
||||
expectedText: ["Details", "Presentation QA workspace", childWorkspacePath, "local"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create agent",
|
||||
artifact: "create-agent-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send agent prompt",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create schedule",
|
||||
artifact: "schedule-presentation",
|
||||
expectedText: [
|
||||
"Prompt",
|
||||
GREETING,
|
||||
"0 0 1 1 *",
|
||||
"Europe/Berlin",
|
||||
"Greeting schedule",
|
||||
"Result",
|
||||
],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create heartbeat",
|
||||
artifact: "heartbeat-presentation",
|
||||
expectedText: [
|
||||
"Prompt",
|
||||
GREETING,
|
||||
"0 0 1 1 *",
|
||||
"Europe/Berlin",
|
||||
"Greeting heartbeat",
|
||||
"Result",
|
||||
],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Archive workspace",
|
||||
artifact: "archive-workspace-presentation",
|
||||
expectedText: ["Details", "Workspace", "Result", "Removed directory"],
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,9 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||
}
|
||||
|
||||
function pluginSource(input: { workspaceId: string; agentId: string }): string {
|
||||
function pluginSource(): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
@@ -55,14 +55,8 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
|
||||
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
|
||||
}
|
||||
|
||||
function DirectCollisionSurface({ navigation }) {
|
||||
return <View>
|
||||
<Text>Direct collision surface</Text>
|
||||
{navigation ? <>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
|
||||
</> : null}
|
||||
</View>;
|
||||
function DirectCollisionSurface() {
|
||||
return <View><Text>Direct collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function SidebarCollisionSurface() {
|
||||
@@ -184,10 +178,7 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource());
|
||||
|
||||
try {
|
||||
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -248,38 +239,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
});
|
||||
|
||||
await test.step("surface navigation opens host-owned workspace and agent routes", async () => {
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await page.getByRole("button", { name: "Open workspace from plugin", exact: true }).click();
|
||||
await page.waitForURL(isSettledWorkspaceUrl);
|
||||
await expect(page.getByTestId("workspace-header-title")).toBeVisible();
|
||||
|
||||
const agent = await primary.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: primary.repoPath,
|
||||
workspaceId: primary.workspaceId,
|
||||
title: "Plugin navigation agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const navigationAgentId = agent.id;
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
|
||||
);
|
||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await page.getByRole("button", { name: "Open agent from plugin", exact: true }).click();
|
||||
await page.waitForURL(isSettledWorkspaceUrl);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-agent_${navigationAgentId}`)
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("switching hosts removes commands from an uninstalled host", async () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneChecks,
|
||||
expectPrPaneActivity,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
} from "../support/helpers/pr-pane";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ test.describe("PR pane", () => {
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -101,21 +102,25 @@ test.describe("PR pane", () => {
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
|
||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneChecks(page, {
|
||||
success: ["build-1", "build-2"],
|
||||
failure: ["deploy"],
|
||||
pending: ["security"],
|
||||
});
|
||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders every seeded activity comment", async ({ page }) => {
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
|
||||
await expectPrPaneActivityCount(page, 3);
|
||||
});
|
||||
|
||||
test("renders gracefully with zero checks", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ const cases: ProviderSubagentCase[] = [
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
expectedName: "Verify OpenCode descriptor",
|
||||
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||
expectsUserMessage: false,
|
||||
providerConfig: { model: "openai/gpt-5.4" },
|
||||
prompt:
|
||||
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
||||
|
||||
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Workspace B (same directory): the SAME directory-backed content is
|
||||
// visible even though the panel tabs belong to a different workspace.
|
||||
@@ -79,8 +79,8 @@ test.describe("Same-directory workspaces", () => {
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
expectTurnCopyButton,
|
||||
} from "../support/helpers/agent-stream";
|
||||
import {
|
||||
expectReconnectingToastDebounced,
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
@@ -180,17 +181,13 @@ test.describe("Viewed agent timelines", () => {
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastDebounced(page);
|
||||
await expectReconnectingToastVisible(page);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
// Hold the first authoritative catch-up response so the assertion observes
|
||||
// the reconnect boundary instead of racing a socket that has not reopened yet.
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await expectReconnectingToastGone(page);
|
||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||
exact: true,
|
||||
@@ -212,7 +209,6 @@ test.describe("Viewed agent timelines", () => {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
createMockIdleAgent,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||
|
||||
try {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
@@ -180,7 +180,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await daemonGate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
@@ -218,12 +217,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
createMockIdleAgent(primaryWorkspace.client, {
|
||||
createIdleAgent(primaryWorkspace.client, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title: "Active host agent",
|
||||
}),
|
||||
createMockIdleAgent(secondaryWorkspace.client, {
|
||||
createIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: "Inactive host agent",
|
||||
@@ -320,12 +319,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
|
||||
STATE_DIR="${PASEO_COMPOSER_KEYBOARD_STATE_DIR:-${REPO_ROOT}/.dev/agent-device-composer-keyboard}"
|
||||
ARTIFACTS_DIR="${REPO_ROOT}/.dev/agent-device-artifacts/composer-keyboard-android"
|
||||
SESSION="${PASEO_COMPOSER_KEYBOARD_SESSION:-composer-keyboard-android}"
|
||||
APP_ID="${PASEO_COMPOSER_KEYBOARD_APP_ID:-sh.paseo.debug}"
|
||||
DEVICE="${PASEO_COMPOSER_KEYBOARD_DEVICE:-paseo-api35}"
|
||||
HELPER_IME="com.callstack.agentdevice.imehelper/.TestInputMethodService"
|
||||
GBOARD_IME="com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"
|
||||
ASSERT="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs"
|
||||
STALL_HERMES="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/stall-hermes.mjs"
|
||||
METRO_PORT="${PASEO_MOBILE_E2E_METRO_PORT:-8082}"
|
||||
DAEMON_HOST="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOST:-127.0.0.1:6770}"
|
||||
DAEMON_HOME="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOME:-${REPO_ROOT}/.dev/composer-e2e-home}"
|
||||
SERVER_ID="${PASEO_COMPOSER_KEYBOARD_SERVER_ID:-}"
|
||||
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(" "))')"
|
||||
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
|
||||
|
||||
if [[ -z "${SERVER_ID}" ]]; then
|
||||
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
|
||||
echo "Missing ${DAEMON_HOME}/server-id; set PASEO_COMPOSER_KEYBOARD_SERVER_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVER_ID="$(<"${DAEMON_HOME}/server-id")"
|
||||
fi
|
||||
|
||||
ad() {
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device "$@" --session "${SESSION}"
|
||||
}
|
||||
|
||||
capture_screen() {
|
||||
local output_path="$1"
|
||||
ad screenshot "${output_path}" >/dev/null
|
||||
}
|
||||
|
||||
snapshot_json() {
|
||||
local output_path="$1"
|
||||
ad snapshot -i --json >"${output_path}"
|
||||
}
|
||||
|
||||
capture_ui_xml() {
|
||||
local output_path="$1"
|
||||
local device_path="/sdcard/paseo-composer-keyboard-window.xml"
|
||||
# Android exposes one UI Automation connection at a time. Release the
|
||||
# persistent agent-device snapshot helper before asking uiautomator for the
|
||||
# app and IME windows; agent-device reconnects it on the next interaction.
|
||||
adb shell am force-stop com.callstack.agentdevice.snapshothelper
|
||||
sleep 0.2
|
||||
adb shell uiautomator dump "${device_path}" >/dev/null
|
||||
adb pull "${device_path}" "${output_path}" >/dev/null
|
||||
}
|
||||
|
||||
clear_input() {
|
||||
ad fill 'editable=true' "x" --settle
|
||||
adb shell input keycombination 113 29
|
||||
adb shell input keyevent 67
|
||||
}
|
||||
|
||||
clear_focused_input_with_adb() {
|
||||
adb shell input keycombination 113 29
|
||||
adb shell input keyevent 67
|
||||
}
|
||||
|
||||
wait_for_ime() {
|
||||
local expected="$1"
|
||||
local previous_top=""
|
||||
local stable_count=0
|
||||
for _ in $(seq 1 30); do
|
||||
local window_state
|
||||
window_state="$(adb shell dumpsys window)"
|
||||
if [[ "${expected}" == "true" ]]; then
|
||||
local ime_top
|
||||
ime_top="$(printf '%s' "${window_state}" | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
|
||||
if [[ -n "${ime_top}" && "${ime_top}" -gt 0 ]]; then
|
||||
if [[ "${ime_top}" == "${previous_top}" ]]; then
|
||||
stable_count="$((stable_count + 1))"
|
||||
else
|
||||
stable_count=0
|
||||
previous_top="${ime_top}"
|
||||
fi
|
||||
if [[ "${stable_count}" -ge 2 ]]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
elif ! printf '%s' "${window_state}" | rg -q "type=ime .*visible=true"; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "IME did not become visible=${expected}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ime_is_visible() {
|
||||
adb shell dumpsys window | rg -q "type=ime .*visible=true"
|
||||
}
|
||||
|
||||
open_gboard() {
|
||||
local x="$1"
|
||||
local y="$2"
|
||||
adb shell ime set "${GBOARD_IME}" >/dev/null
|
||||
if ime_is_visible; then
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
fi
|
||||
adb shell input tap "${x}" "${y}"
|
||||
wait_for_ime true
|
||||
local keyboard_shift
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${x}" "$((y - keyboard_shift))"
|
||||
}
|
||||
|
||||
read_keyboard_shift() {
|
||||
local ime_top
|
||||
local navigation_top
|
||||
ime_top="$(adb shell dumpsys window | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
|
||||
navigation_top="$(adb shell dumpsys window | sed -n 's/.*type=navigationBars frame=\[0,\([0-9][0-9]*\)\].*/\1/p' | head -1)"
|
||||
printf '%s\n' "$((navigation_top - ime_top))"
|
||||
}
|
||||
|
||||
read_ime_top() {
|
||||
adb shell dumpsys window | sed -n \
|
||||
's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null 2>&1 || true
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device daemon stop --clean >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
cleanup
|
||||
mkdir -p "${STATE_DIR}" "${ARTIFACTS_DIR}"
|
||||
|
||||
workspaces_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- workspace ls --json --host "${DAEMON_HOST}")"
|
||||
workspace_id="$(node -e '
|
||||
const workspaces = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
const cwd = process.argv[1];
|
||||
const workspace = workspaces.find((candidate) => candidate.cwd === cwd);
|
||||
if (!workspace) process.exit(1);
|
||||
process.stdout.write(workspace.workspaceId);
|
||||
' "${REPO_ROOT}" <<<"${workspaces_json}")"
|
||||
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device open "${APP_ID}" \
|
||||
--platform android \
|
||||
--device "${DEVICE}" \
|
||||
--session "${SESSION}"
|
||||
adb shell am force-stop "${APP_ID}"
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A${METRO_PORT}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
sleep 3
|
||||
|
||||
# Create the fixture while the client is connected so its directory replica observes the insert.
|
||||
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" \
|
||||
--background \
|
||||
--title "${AGENT_TITLE}" \
|
||||
--provider mock \
|
||||
--model e2e-fast-stream \
|
||||
--workspace "${workspace_id}" \
|
||||
--cwd "${REPO_ROOT}" \
|
||||
--host "${DAEMON_HOST}" \
|
||||
--json)"
|
||||
agent_id="$(node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
process.stdout.write(result.agentId);
|
||||
' <<<"${run_json}")"
|
||||
sleep 3
|
||||
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
sleep 5
|
||||
# The development-client bootstrap can consume the first link while Expo Router is mounting.
|
||||
# Deliver the target again after the root navigator is live.
|
||||
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}\"" 45000
|
||||
ad wait 'editable=true' 10000
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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}"
|
||||
|
||||
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))"
|
||||
|
||||
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)"
|
||||
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
|
||||
|
||||
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}"
|
||||
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
|
||||
|
||||
echo "Composer keyboard invariants passed"
|
||||
@@ -1,106 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import sharp from "sharp";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
if (command === "rect") {
|
||||
const [snapshotPath, identifier] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const node = snapshot.data.nodes.find(
|
||||
(candidate) => candidate.identifier === identifier || candidate.label === identifier,
|
||||
);
|
||||
if (!node) throw new Error(`Missing node: ${identifier}`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
process.stdout.write(`${Math.round(x + width / 2)} ${Math.round(y + height / 2)} ${height}\n`);
|
||||
} else if (command === "above-y") {
|
||||
const [snapshotPath, upperIdentifier, lowerYArgument] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const upper = snapshot.data.nodes.find(
|
||||
(candidate) => candidate.identifier === upperIdentifier || candidate.label === upperIdentifier,
|
||||
);
|
||||
if (!upper) throw new Error(`Missing node: ${upperIdentifier}`);
|
||||
const upperBottom = upper.rect.y + upper.rect.height;
|
||||
const lowerY = Number(lowerYArgument);
|
||||
if (upperBottom > lowerY) {
|
||||
throw new Error(
|
||||
`${upperIdentifier} extends below the composer top: ${upperBottom} > ${lowerY}`,
|
||||
);
|
||||
}
|
||||
} else if (command === "xml-above-y") {
|
||||
const [snapshotPath, contentDescription, lowerYArgument] = args;
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const escapedDescription = contentDescription.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = snapshot.match(
|
||||
new RegExp(
|
||||
`<node[^>]*content-desc="${escapedDescription}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`,
|
||||
),
|
||||
);
|
||||
if (!match) throw new Error(`Missing node: ${contentDescription}`);
|
||||
const upperBottom = Number(match[4]);
|
||||
const lowerY = Number(lowerYArgument);
|
||||
if (upperBottom > lowerY) {
|
||||
throw new Error(`${contentDescription} extends below the keyboard: ${upperBottom} > ${lowerY}`);
|
||||
}
|
||||
} else if (command === "same-header") {
|
||||
const [baselinePath, keyboardPath, headerBottomArgument] = args;
|
||||
const headerBottom = Number(headerBottomArgument);
|
||||
const crop = { left: 0, top: 64, width: 1080, height: headerBottom - 64 };
|
||||
const [baseline, keyboard] = await Promise.all([
|
||||
sharp(baselinePath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
sharp(keyboardPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
]);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < baseline.length; index += 1) {
|
||||
if (Math.abs(baseline[index] - keyboard[index]) > 8) changed += 1;
|
||||
}
|
||||
const changedRatio = changed / baseline.length;
|
||||
if (changedRatio > 0.01) {
|
||||
throw new Error(`Header changed while the keyboard opened (${changedRatio.toFixed(3)})`);
|
||||
}
|
||||
} else if (command === "same-input-height") {
|
||||
const [firstPath, secondPath] = args;
|
||||
const readInputHeight = async (snapshotPath) => {
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
|
||||
if (!input) throw new Error(`Missing composer input in ${snapshotPath}`);
|
||||
return input.rect.height;
|
||||
};
|
||||
const [firstHeight, secondHeight] = await Promise.all([
|
||||
readInputHeight(firstPath),
|
||||
readInputHeight(secondPath),
|
||||
]);
|
||||
if (firstHeight !== secondHeight) {
|
||||
throw new Error(`Composer height drifted from ${firstHeight} to ${secondHeight}`);
|
||||
}
|
||||
} else if (command === "has-exact-text") {
|
||||
const [snapshotPath, expected] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
|
||||
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
|
||||
}
|
||||
} else if (command === "same-region") {
|
||||
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
|
||||
const crop = {
|
||||
left: 0,
|
||||
top: Number(topArgument),
|
||||
width: 1080,
|
||||
height: Number(heightArgument),
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
sharp(firstPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
sharp(secondPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
]);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < first.length; index += 1) {
|
||||
if (Math.abs(first[index] - second[index]) > 8) changed += 1;
|
||||
}
|
||||
const changedRatio = changed / first.length;
|
||||
if (changedRatio > 0.02) {
|
||||
throw new Error(
|
||||
`${failureMessage ?? "Composer region changed during the JS stall"} (${changedRatio.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import process from "node:process";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const [, , metroPortArgument, durationArgument, appId] = process.argv;
|
||||
const metroPort = Number(metroPortArgument);
|
||||
const duration = Number(durationArgument);
|
||||
const targets = await fetch(`http://127.0.0.1:${metroPort}/json/list`).then((response) =>
|
||||
response.json(),
|
||||
);
|
||||
const target = targets.find((candidate) => candidate.appId === appId);
|
||||
if (!target) throw new Error(`Missing Hermes target for ${appId}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
const timeout = setTimeout(() => reject(new Error("Hermes stall timed out")), duration + 5000);
|
||||
socket.on("open", () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: "Runtime.evaluate",
|
||||
params: {
|
||||
expression: `(() => { const deadline = Date.now() + ${duration}; while (Date.now() < deadline) {} })()`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
socket.on("message", (message) => {
|
||||
const response = JSON.parse(message.toString());
|
||||
if (response.id !== 1) return;
|
||||
clearTimeout(timeout);
|
||||
socket.close();
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
@@ -34,20 +34,13 @@ const metroTest = base.extend({
|
||||
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
{
|
||||
e2eForkProviders: string[];
|
||||
e2eInjectPaseoTools: boolean;
|
||||
e2eWorker: void;
|
||||
e2eWorkerClient: SeedDaemonClient;
|
||||
}
|
||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
||||
e2eWorker: [
|
||||
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
|
||||
async ({ e2eForkProviders }, provide, workerInfo) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||
forkProviders: e2eForkProviders,
|
||||
injectPaseoTools: e2eInjectPaseoTools,
|
||||
});
|
||||
try {
|
||||
await provide();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
@@ -14,31 +14,6 @@ function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
interface PositionedToolCall {
|
||||
locator: Locator;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
|
||||
const entries = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
locator: toolCalls.nth(index),
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
return entries.filter(
|
||||
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
|
||||
return toolCalls.toSorted(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
|
||||
)[0];
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
@@ -205,53 +180,58 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const initialToolCalls = await readRenderedToolCalls(toolCalls);
|
||||
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
expect(
|
||||
initialCandidate,
|
||||
candidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
|
||||
toolCallBounds,
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
if (!currentButtonBounds) return false;
|
||||
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
|
||||
const closest = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
);
|
||||
if (!closest) return false;
|
||||
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
|
||||
if (
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
|
||||
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
|
||||
(scroll as HTMLElement).scrollTop += delta;
|
||||
}, deltaY);
|
||||
return false;
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
|
||||
const alignedToolCall = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
)?.locator;
|
||||
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall!.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
@@ -265,7 +245,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
|
||||
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
async function seedIdleAgent(
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
provider: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
...provider,
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
@@ -84,32 +84,6 @@ async function seedIdleAgent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMockIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||
agentId: string,
|
||||
|
||||
@@ -38,10 +38,6 @@ export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -53,10 +49,6 @@ export async function fillComposerDraft(page: Page, text: string): Promise<void>
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function typeIntoFocusedComposer(page: Page, text: string): Promise<void> {
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
@@ -315,9 +315,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const suppressedAgentStreamItemTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
let blockedConnectionCount = 0;
|
||||
let blockedConnectionCountAtDrop = 0;
|
||||
const blockedConnectionWaiters = new Set<() => void>();
|
||||
let latestServer: WebSocketRoute | null = null;
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
@@ -446,9 +443,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
blockedConnectionCount += 1;
|
||||
for (const resolve of blockedConnectionWaiters) resolve();
|
||||
blockedConnectionWaiters.clear();
|
||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||
return;
|
||||
}
|
||||
@@ -589,7 +583,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
forward?.();
|
||||
},
|
||||
async drop(): Promise<void> {
|
||||
blockedConnectionCountAtDrop = blockedConnectionCount;
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
@@ -599,10 +592,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
),
|
||||
);
|
||||
},
|
||||
async waitForBlockedConnection(): Promise<void> {
|
||||
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
|
||||
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -156,7 +155,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
||||
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
|
||||
options: { forkProviders?: string[] } = {},
|
||||
): Promise<E2EWorker> {
|
||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||
const paseoHome = requestedRoot
|
||||
@@ -169,9 +168,6 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
if (options.injectPaseoTools) {
|
||||
await enablePaseoTools(paseoHome);
|
||||
}
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
paseoHome,
|
||||
preserveHome,
|
||||
@@ -205,28 +201,3 @@ export async function startE2EWorker(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function enablePaseoTools(paseoHome: string): Promise<void> {
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
const existing = existsSync(configPath)
|
||||
? JSON.parse(await readFile(configPath, "utf8"))
|
||||
: { version: 1 };
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
daemon: {
|
||||
...existing.daemon,
|
||||
mcp: {
|
||||
...existing.daemon?.mcp,
|
||||
enabled: true,
|
||||
injectIntoAgents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { CheckPresentationCounts } from "@/git/check-presentation";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
@@ -19,31 +20,23 @@ export async function expectPrPaneState(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPrPaneChecks(
|
||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
||||
const locator = page.getByTestId(testId);
|
||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
||||
if (count > 0) {
|
||||
await expect(locator).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
checks: { success: string[]; failure: string[]; pending: string[] },
|
||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
||||
): Promise<void> {
|
||||
const section = page.getByTestId("pr-pane-checks");
|
||||
const groups = [
|
||||
{ status: "failure", label: /failing checks?$/, names: checks.failure },
|
||||
{ status: "pending", label: /in progress checks?$/, names: checks.pending },
|
||||
{ status: "success", label: /successful checks?$/, names: checks.success },
|
||||
];
|
||||
|
||||
for (const group of groups) {
|
||||
const groupSection = section.getByTestId(`pr-pane-check-group-${group.status}`);
|
||||
await expect(groupSection.getByRole("button", { name: group.label })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
for (const name of group.names) {
|
||||
await expect(groupSection.getByText(name, { exact: true })).toHaveCount(1);
|
||||
}
|
||||
}
|
||||
await assertCheckPill(page, "pr-pane-check-success", counts.success);
|
||||
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
export async function expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
|
||||
const activity = page.getByTestId("pr-pane-activity-row");
|
||||
for (const body of bodies) {
|
||||
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
@@ -102,16 +102,16 @@ export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page, timeout = 30_000): Promise<void> {
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
const changedFile = page
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(changedFile).toBeVisible({ timeout });
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await changedFile.click();
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout,
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,14 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastDebounced(
|
||||
page: Page,
|
||||
options?: { durationMs?: number },
|
||||
): Promise<void> {
|
||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -162,10 +162,5 @@
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.105.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"reanimated": {
|
||||
"staticFeatureFlags": {
|
||||
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
[
|
||||
assistantId,
|
||||
{
|
||||
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||
durationMs: 2_000,
|
||||
},
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamHistoryRowRevision } from "./strategy";
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
}
|
||||
|
||||
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
||||
|
||||
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
||||
let variants = historyRowDisplayVariants.get(item);
|
||||
if (!variants) {
|
||||
variants = {};
|
||||
historyRowDisplayVariants.set(item, variants);
|
||||
}
|
||||
const key = compact ? "compact" : "regular";
|
||||
variants[key] ??= { ...item };
|
||||
return variants[key];
|
||||
}
|
||||
|
||||
// Item identity is the render signal for history rows: the row boundary in view.tsx bails out
|
||||
// until its item changes. Every viewport runs its history through this hook so a history host
|
||||
// whose tool-call group keeps updating from the live head, a group whose expanded state changed,
|
||||
// or a breakpoint change reaches the row as a fresh identity. Unchanged rows keep their identity,
|
||||
// which is what limits a live update to the rows it touched.
|
||||
export function useRevisedHistoryRows(
|
||||
items: StreamItem[],
|
||||
revision: StreamHistoryRowRevision | undefined,
|
||||
): StreamItem[] {
|
||||
const globalDisplayState = revision?.globalDisplayState ?? false;
|
||||
const displayStateById = revision?.displayStateById;
|
||||
const contentById = revision?.contentById;
|
||||
const globallyRevisedRows = useMemo(
|
||||
() => items.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)),
|
||||
[items, globalDisplayState],
|
||||
);
|
||||
const displayStateRevisedRows = useMemo(
|
||||
() => globallyRevisedRows.map((item) => (displayStateById?.has(item.id) ? { ...item } : item)),
|
||||
[globallyRevisedRows, displayStateById],
|
||||
);
|
||||
return useMemo(
|
||||
() => displayStateRevisedRows.map((item) => (contentById?.has(item.id) ? { ...item } : item)),
|
||||
[displayStateRevisedRows, contentById],
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,7 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
|
||||
|
||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||
const timing = {
|
||||
startedAt: timestamp(1),
|
||||
completedAt: timestamp(9),
|
||||
durationMs: 8000,
|
||||
};
|
||||
@@ -365,23 +366,6 @@ describe("layoutStream", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps layout item identity for rows whose layout did not change", () => {
|
||||
const user = userMessage("u1", 1);
|
||||
const first = toolCall("tool-1", 2);
|
||||
const second = toolCall("tool-2", 3);
|
||||
const before = layoutFor({ platform: "android", tail: [user, first, second] });
|
||||
|
||||
const third = toolCall("tool-3", 4);
|
||||
const after = layoutFor({ platform: "android", tail: [user, first, second, third] });
|
||||
|
||||
// A newest-first list shifts every index, but only the rows next to the insertion change.
|
||||
expect(findLayoutItem(after, user.id)).toBe(findLayoutItem(before, user.id));
|
||||
expect(findLayoutItem(after, first.id)).toBe(findLayoutItem(before, first.id));
|
||||
expect(findLayoutItem(after, second.id)).not.toBe(findLayoutItem(before, second.id));
|
||||
expect(findLayoutItem(after, second.id).isLastInToolSequence).toBe(false);
|
||||
expect(findLayoutItem(after, third.id).isLastInToolSequence).toBe(true);
|
||||
});
|
||||
|
||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface TurnFooterHost {
|
||||
|
||||
export interface StreamLayoutItem {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
gapBelow: number;
|
||||
@@ -230,109 +232,60 @@ function getSegmentNeighbor(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Last layout emitted for each stream item. A row only rerenders when its layout item identity
|
||||
// changes, so an item whose render-relevant layout is unchanged must keep its previous object even
|
||||
// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
|
||||
const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
|
||||
|
||||
function areTurnFooterHostsEqual(
|
||||
left: TurnFooterHost | null,
|
||||
right: TurnFooterHost | null,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
return (
|
||||
left.itemId === right.itemId &&
|
||||
left.timing === right.timing &&
|
||||
left.startIndex === right.startIndex &&
|
||||
left.items === right.items
|
||||
);
|
||||
}
|
||||
|
||||
function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
|
||||
return (
|
||||
previous.item === next.item &&
|
||||
previous.aboveItem === next.aboveItem &&
|
||||
previous.belowItem === next.belowItem &&
|
||||
previous.gapBelow === next.gapBelow &&
|
||||
previous.assistantSpacing === next.assistantSpacing &&
|
||||
areTurnFooterHostsEqual(previous.completedFooter, next.completedFooter) &&
|
||||
previous.toolSequence === next.toolSequence &&
|
||||
previous.isFirstInUserGroup === next.isFirstInUserGroup &&
|
||||
previous.isLastInUserGroup === next.isLastInUserGroup &&
|
||||
previous.isLastInToolSequence === next.isLastInToolSequence &&
|
||||
previous.frameOrder === next.frameOrder &&
|
||||
previous.phase === next.phase
|
||||
);
|
||||
}
|
||||
|
||||
function shareLayoutItem(next: StreamLayoutItem): StreamLayoutItem {
|
||||
const previous = previousLayoutItemByStreamItem.get(next.item);
|
||||
if (previous && areLayoutItemsEquivalent(previous, next)) {
|
||||
return previous;
|
||||
}
|
||||
previousLayoutItemByStreamItem.set(next.item, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
return input.items.map((item, index) => layoutSegmentItem(input, item, index));
|
||||
}
|
||||
return input.items.map((item, index) => {
|
||||
const aboveItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "above",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryAboveItem,
|
||||
});
|
||||
const belowItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
|
||||
function layoutSegmentItem(
|
||||
input: LayoutSegmentInput,
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
): StreamLayoutItem {
|
||||
const aboveItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "above",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryAboveItem,
|
||||
});
|
||||
const belowItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
|
||||
return shareLayoutItem({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||
assistantSpacing,
|
||||
completedFooter,
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence:
|
||||
isToolSequenceItem(item) &&
|
||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
items: input.items,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||
assistantSpacing,
|
||||
completedFooter,
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence:
|
||||
isToolSequenceItem(item) &&
|
||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||
startedAt: tail[2]?.timestamp,
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
@@ -175,6 +176,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -194,6 +196,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
@@ -64,6 +63,24 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
}
|
||||
|
||||
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
||||
|
||||
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
||||
let variants = historyRowDisplayVariants.get(item);
|
||||
if (!variants) {
|
||||
variants = {};
|
||||
historyRowDisplayVariants.set(item, variants);
|
||||
}
|
||||
const key = compact ? "compact" : "regular";
|
||||
variants[key] ??= { ...item };
|
||||
return variants[key];
|
||||
}
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
}
|
||||
@@ -121,7 +138,27 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
|
||||
// Keep unchanged item identities intact so live updates only rerender rows
|
||||
// whose projected content or local display state actually changed. A rare
|
||||
// breakpoint change intentionally refreshes the whole history window.
|
||||
const globallyRevisedHistoryRows = useMemo(() => {
|
||||
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
|
||||
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
|
||||
}, [historyItems, historyRowRevision?.globalDisplayState]);
|
||||
const displayStateHistoryRows = useMemo(
|
||||
() =>
|
||||
globallyRevisedHistoryRows.map((item) =>
|
||||
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
|
||||
),
|
||||
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
|
||||
);
|
||||
const historyRows = useMemo(
|
||||
() =>
|
||||
displayStateHistoryRows.map((item) =>
|
||||
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
|
||||
),
|
||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
||||
);
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
const hasMeasuredViewport =
|
||||
|
||||
@@ -252,74 +252,6 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rerenders only the history row whose content revision changed", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const host = userMessage(1);
|
||||
const other = userMessage(2);
|
||||
const renderCountById = new Map<string, number>();
|
||||
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
|
||||
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
|
||||
return <div>{item.id}</div>;
|
||||
});
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [host, other],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: {
|
||||
...createRenderers(vi.fn()),
|
||||
renderHistoryMountedRow: (item) => <HistoryRow item={item} />,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
const unrevised = {
|
||||
contentById: new Set<string>(),
|
||||
displayStateById: new Set<string>(),
|
||||
globalDisplayState: false,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised }));
|
||||
});
|
||||
expect(renderCountById.get(host.id)).toBe(1);
|
||||
expect(renderCountById.get(other.id)).toBe(1);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
historyRowRevision: { ...unrevised, contentById: new Set([host.id]) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(renderCountById.get(host.id)).toBe(2);
|
||||
expect(renderCountById.get(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
} from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
@@ -280,8 +279,7 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
segments: inputSegments,
|
||||
historyRowRevision,
|
||||
segments,
|
||||
liveHeadRowRevision,
|
||||
boundary,
|
||||
renderers,
|
||||
@@ -298,15 +296,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
inputSegments.historyVirtualized,
|
||||
historyRowRevision,
|
||||
);
|
||||
const historyMounted = useRevisedHistoryRows(inputSegments.historyMounted, historyRowRevision);
|
||||
const segments = useMemo(
|
||||
() => ({ ...inputSegments, historyVirtualized, historyMounted }),
|
||||
[historyMounted, historyVirtualized, inputSegments],
|
||||
);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -86,13 +86,7 @@ describe("canonical turn membership", () => {
|
||||
assistant("heartbeat-2-reply", 6, "turn-3"),
|
||||
];
|
||||
|
||||
const completed = layoutFor(items, false);
|
||||
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||
completedAt: at(6),
|
||||
durationMs: null,
|
||||
});
|
||||
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -124,6 +118,7 @@ describe("canonical turn membership", () => {
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
startedAt: at(1),
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
|
||||
@@ -220,22 +220,6 @@ function renderListEmptyComponent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
|
||||
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
|
||||
// what stops that churn: a row renders again only when its stream item identity, its layout item
|
||||
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
|
||||
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
|
||||
const HistoryStreamRow = memo(function HistoryStreamRow({
|
||||
layoutItem,
|
||||
renderStreamItem,
|
||||
}: {
|
||||
item: StreamItem;
|
||||
layoutItem: StreamLayoutItem;
|
||||
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||
}) {
|
||||
return <>{renderStreamItem(layoutItem)}</>;
|
||||
});
|
||||
|
||||
function renderHistoryStreamItem(input: {
|
||||
item: StreamItem;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
@@ -245,13 +229,7 @@ function renderHistoryStreamItem(input: {
|
||||
if (!layoutItem) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HistoryStreamRow
|
||||
item={input.item}
|
||||
layoutItem={layoutItem}
|
||||
renderStreamItem={input.renderStreamItem}
|
||||
/>
|
||||
);
|
||||
return input.renderStreamItem(layoutItem);
|
||||
}
|
||||
|
||||
function renderLiveHeadStreamItem(input: {
|
||||
@@ -811,14 +789,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
// Read through a stable event so live group updates do not change the renderer identity
|
||||
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
|
||||
const getToolCallGroup = useStableEvent((hostId: string) =>
|
||||
projectedToolCalls.groupsByHostId.get(hostId),
|
||||
);
|
||||
const renderToolCallItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const group = getToolCallGroup(item.id);
|
||||
const group = projectedToolCalls.groupsByHostId.get(item.id);
|
||||
if (!group) {
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
@@ -845,8 +818,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
},
|
||||
[
|
||||
projectedToolCalls.groupsByHostId,
|
||||
expandedToolCallGroupIds,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
],
|
||||
|
||||
@@ -39,7 +39,6 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||
@@ -95,7 +94,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import {
|
||||
KeyboardActionDispatcherProvider,
|
||||
@@ -114,7 +113,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -596,7 +595,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
@@ -627,9 +625,10 @@ function SidebarChrome({
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
const active = visible && isOpen;
|
||||
return (
|
||||
<SidebarModelProvider active={active}>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
|
||||
@@ -9,19 +9,17 @@ import {
|
||||
History,
|
||||
Home,
|
||||
Keyboard,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
@@ -56,9 +54,6 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for
|
||||
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
function PlusIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPlus size={size} strokeWidth={2.4} />;
|
||||
@@ -96,10 +91,6 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function PanelLeftIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPanelLeft size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
export function CommandCenterRootActions() {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
@@ -115,10 +106,6 @@ export function CommandCenterRootActions() {
|
||||
// each time host filters are reconciled.
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const shortcutPlatform = useMemo(
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
[],
|
||||
@@ -240,33 +227,6 @@ export function CommandCenterRootActions() {
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
// Toggle left sidebar is global: it calls the panel store directly and works on every route.
|
||||
// The right sidebar and focus toggles do NOT belong here — their handlers live in
|
||||
// workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally
|
||||
// would list entries that silently no-op off a workspace route. They live in
|
||||
// workspace-contributions.ts instead. That is why the three toggles render in two
|
||||
// non-adjacent sections; don't "tidy" them back together.
|
||||
{
|
||||
id: "toggle-left-sidebar",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 7,
|
||||
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
toggleAgentList();
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("settings.shortcuts.help.toggleLeftSidebar"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: PanelLeftIcon,
|
||||
shortcutKeys:
|
||||
resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ??
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (shortcutsAvailable) {
|
||||
@@ -318,7 +278,6 @@ export function CommandCenterRootActions() {
|
||||
shortcutPlatform,
|
||||
shortcutsAvailable,
|
||||
t,
|
||||
toggleAgentList,
|
||||
]);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
|
||||
@@ -23,14 +23,9 @@ function source(gitActions: GitActions): {
|
||||
value: WorkspaceCommandCenterSource;
|
||||
runGitActions: GitAction[];
|
||||
dispatched: KeyboardActionDefinition[];
|
||||
copiedPaths: number;
|
||||
copiedBranchNames: number;
|
||||
toggledLabels: Array<{ name: string; assigned: boolean }>;
|
||||
} {
|
||||
const runGitActions: GitAction[] = [];
|
||||
const dispatched: KeyboardActionDefinition[] = [];
|
||||
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
|
||||
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
|
||||
return {
|
||||
value: {
|
||||
gitActions,
|
||||
@@ -67,51 +62,19 @@ function source(gitActions: GitActions): {
|
||||
moveTabDown: "Move tab down",
|
||||
closePane: "Close pane",
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
toggleExplorerSidebar: "Toggle Explorer sidebar",
|
||||
rename: "Rename workspace",
|
||||
copyPath: "Copy workspace path",
|
||||
copyBranchName: "Copy branch name",
|
||||
pin: "Pin to top",
|
||||
unpin: "Unpin",
|
||||
showSetup: "Show setup",
|
||||
labelsGroup: "Labels",
|
||||
toggleExplorerSidebar: "Toggle side panel",
|
||||
},
|
||||
icons: {},
|
||||
shortcuts: {},
|
||||
capabilities: {
|
||||
canSplitPanes: true,
|
||||
canOpenBrowserTabs: true,
|
||||
isGit: false,
|
||||
canPin: false,
|
||||
canShowSetup: false,
|
||||
},
|
||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||
activeTabKind: null,
|
||||
activeTabIndex: -1,
|
||||
activeTabCount: 0,
|
||||
currentBranch: null,
|
||||
isPinned: false,
|
||||
labelCatalog: null,
|
||||
dispatch: (action) => dispatched.push(action),
|
||||
runGitAction: (action) => runGitActions.push(action),
|
||||
copyPath: () => {
|
||||
counters.copiedPaths += 1;
|
||||
},
|
||||
copyBranchName: () => {
|
||||
counters.copiedBranchNames += 1;
|
||||
},
|
||||
toggleLabel: (name, assigned) => {
|
||||
toggledLabels.push({ name, assigned });
|
||||
},
|
||||
},
|
||||
runGitActions,
|
||||
dispatched,
|
||||
toggledLabels,
|
||||
get copiedPaths() {
|
||||
return counters.copiedPaths;
|
||||
},
|
||||
get copiedBranchNames() {
|
||||
return counters.copiedBranchNames;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,8 +147,6 @@ describe("workspace command center contributions", () => {
|
||||
canSplitPanes: false,
|
||||
canOpenBrowserTabs: false,
|
||||
isGit: false,
|
||||
canPin: false,
|
||||
canShowSetup: false,
|
||||
};
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
@@ -194,8 +155,6 @@ describe("workspace command center contributions", () => {
|
||||
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
|
||||
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
|
||||
expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true);
|
||||
});
|
||||
|
||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||
@@ -205,14 +164,7 @@ describe("workspace command center contributions", () => {
|
||||
for (const contribution of contributions) contribution.run();
|
||||
|
||||
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||
// workspace:rename dispatches to workspace scope
|
||||
expect(
|
||||
fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"),
|
||||
).toBe(true);
|
||||
// workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope
|
||||
expect(
|
||||
fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"),
|
||||
).toBe(true);
|
||||
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps workspace creation commands available outside Git", () => {
|
||||
@@ -227,164 +179,4 @@ describe("workspace command center contributions", () => {
|
||||
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches rename to the workspace scope", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
const rename = contributions.find((item) => item.id === "workspace:rename");
|
||||
expect(rename?.presentation).toMatchObject({ title: "Rename workspace" });
|
||||
rename?.run();
|
||||
|
||||
expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]);
|
||||
});
|
||||
|
||||
it("copies the workspace path without going through the dispatcher", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
contributions.find((item) => item.id === "workspace:copy-path")?.run();
|
||||
|
||||
expect(fixture.copiedPaths).toBe(1);
|
||||
expect(fixture.dispatched).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits Copy branch name when the workspace has no branch, and copies when it does", () => {
|
||||
const withoutBranch = source({ primary: null, secondary: [], menu: [] });
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(withoutBranch.value).some(
|
||||
(item) => item.id === "workspace:copy-branch-name",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const withBranch = source({ primary: null, secondary: [], menu: [] });
|
||||
withBranch.value.currentBranch = "feature/login";
|
||||
const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find(
|
||||
(item) => item.id === "workspace:copy-branch-name",
|
||||
);
|
||||
|
||||
expect(contribution?.keywords).toContain("feature/login");
|
||||
contribution?.run();
|
||||
expect(withBranch.copiedBranchNames).toBe(1);
|
||||
});
|
||||
|
||||
it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => {
|
||||
const unpinned = source({ primary: null, secondary: [], menu: [] });
|
||||
unpinned.value.capabilities.canPin = true;
|
||||
const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find(
|
||||
(item) => item.id === "workspace:pin",
|
||||
);
|
||||
expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" });
|
||||
expect(pinContribution?.visibility).toBe("always");
|
||||
pinContribution?.run();
|
||||
expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]);
|
||||
|
||||
const pinned = source({ primary: null, secondary: [], menu: [] });
|
||||
pinned.value.capabilities.canPin = true;
|
||||
pinned.value.isPinned = true;
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(pinned.value).find(
|
||||
(item) => item.id === "workspace:pin",
|
||||
)?.presentation,
|
||||
).toMatchObject({ title: "Unpin" });
|
||||
|
||||
const unsupported = source({ primary: null, secondary: [], menu: [] });
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(unsupported.value).some(
|
||||
(item) => item.id === "workspace:pin",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lists Show setup only when the workspace has setup to show", () => {
|
||||
const withoutSetup = source({ primary: null, secondary: [], menu: [] });
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(withoutSetup.value).some(
|
||||
(item) => item.id === "workspace:show-setup",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const withSetup = source({ primary: null, secondary: [], menu: [] });
|
||||
withSetup.value.capabilities.canShowSetup = true;
|
||||
buildWorkspaceCommandCenterContributions(withSetup.value)
|
||||
.find((item) => item.id === "workspace:show-setup")
|
||||
?.run();
|
||||
|
||||
expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]);
|
||||
});
|
||||
|
||||
// Regression guard for the registration split: these two are handled only by workspace-screen.tsx
|
||||
// behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global
|
||||
// root set, where they would silently no-op off a workspace route.
|
||||
it("builds the Explorer sidebar and focus toggles in the workspace set", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.capabilities.canSplitPanes = false;
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run();
|
||||
contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run();
|
||||
|
||||
expect(fixture.dispatched).toEqual([
|
||||
{ id: "sidebar.toggle.right", scope: "sidebar" },
|
||||
{ id: "workspace.focus.toggle", scope: "workspace" },
|
||||
]);
|
||||
for (const contribution of contributions) {
|
||||
expect(contribution.group).toBe("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
// `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split
|
||||
// panes are available, so the standalone entry must step aside there — otherwise the palette
|
||||
// lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action.
|
||||
it("omits the standalone focus toggle when the pane set already covers it", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.capabilities.canSplitPanes = true;
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
const focusToggles = contributions.filter(
|
||||
(item) =>
|
||||
item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode",
|
||||
);
|
||||
expect(focusToggles).toHaveLength(1);
|
||||
expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle");
|
||||
});
|
||||
|
||||
it("omits the labels group when the catalog hasn't loaded", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.labelCatalog = [
|
||||
{ name: "bug", assigned: true },
|
||||
{ name: "urgent", assigned: false },
|
||||
];
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
const bug = contributions.find((item) => item.id === "workspace:label:bug");
|
||||
const urgent = contributions.find((item) => item.id === "workspace:label:urgent");
|
||||
|
||||
expect(bug?.presentation).toMatchObject({
|
||||
kind: "choice",
|
||||
path: ["Labels", "bug"],
|
||||
selected: true,
|
||||
});
|
||||
expect(urgent?.presentation).toMatchObject({
|
||||
kind: "choice",
|
||||
path: ["Labels", "urgent"],
|
||||
selected: false,
|
||||
});
|
||||
|
||||
bug?.run();
|
||||
urgent?.run();
|
||||
|
||||
expect(fixture.toggledLabels).toEqual([
|
||||
{ name: "bug", assigned: false },
|
||||
{ name: "urgent", assigned: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,6 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
export interface WorkspaceCommandCenterLabelChoice {
|
||||
name: string;
|
||||
/** Whether the current workspace already carries this label. */
|
||||
assigned: boolean;
|
||||
icon?: CommandCenterIcon;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterLabels {
|
||||
section: string;
|
||||
newAgent: string;
|
||||
@@ -49,14 +42,6 @@ export interface WorkspaceCommandCenterLabels {
|
||||
closePane: string;
|
||||
toggleFocusMode: string;
|
||||
toggleExplorerSidebar: string;
|
||||
// Workspace management actions
|
||||
rename: string;
|
||||
copyPath: string;
|
||||
copyBranchName: string;
|
||||
pin: string;
|
||||
unpin: string;
|
||||
showSetup: string;
|
||||
labelsGroup: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterIcons {
|
||||
@@ -78,14 +63,6 @@ export interface WorkspaceCommandCenterIcons {
|
||||
moveTab?: CommandCenterIcon;
|
||||
focusMode?: CommandCenterIcon;
|
||||
explorerSidebar?: CommandCenterIcon;
|
||||
// Workspace management action icons
|
||||
copyPath?: CommandCenterIcon;
|
||||
copyBranchName?: CommandCenterIcon;
|
||||
pin?: CommandCenterIcon;
|
||||
unpin?: CommandCenterIcon;
|
||||
showSetup?: CommandCenterIcon;
|
||||
toggleFocusMode?: CommandCenterIcon;
|
||||
label?: CommandCenterIcon;
|
||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||
}
|
||||
|
||||
@@ -101,7 +78,6 @@ export interface WorkspaceCommandCenterShortcuts {
|
||||
closePane?: ShortcutKey[][];
|
||||
toggleFocusMode?: ShortcutKey[][];
|
||||
toggleExplorerSidebar?: ShortcutKey[][];
|
||||
pinWorkspace?: ShortcutKey[][];
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterSource {
|
||||
@@ -113,27 +89,12 @@ export interface WorkspaceCommandCenterSource {
|
||||
canSplitPanes: boolean;
|
||||
canOpenBrowserTabs: boolean;
|
||||
isGit: boolean;
|
||||
/** Host supports the `workspacePinning` feature. */
|
||||
canPin: boolean;
|
||||
/** The workspace has setup commands or a setup error — same gate as the header menu. */
|
||||
canShowSetup: boolean;
|
||||
};
|
||||
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||
activeTabIndex: number;
|
||||
activeTabCount: number;
|
||||
/** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */
|
||||
currentBranch: string | null;
|
||||
isPinned: boolean;
|
||||
/**
|
||||
* The host's label catalog, each entry told whether the current workspace carries it. Null
|
||||
* before the catalog has loaded — omits the whole group rather than showing it empty.
|
||||
*/
|
||||
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
|
||||
dispatch(action: KeyboardActionDefinition): void;
|
||||
runGitAction(action: GitAction): void;
|
||||
copyPath(): void;
|
||||
copyBranchName(): void;
|
||||
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
|
||||
}
|
||||
|
||||
function buildGitContribution(
|
||||
@@ -498,35 +459,6 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
|
||||
return paneActions.map((action) => buildQueryAction(source, action));
|
||||
}
|
||||
|
||||
function buildWorkspaceCallback(input: {
|
||||
source: WorkspaceCommandCenterSource;
|
||||
id: string;
|
||||
rank: number;
|
||||
title: string;
|
||||
keywords: readonly string[];
|
||||
icon?: CommandCenterIcon;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
run: () => void;
|
||||
visibility: "always" | "query";
|
||||
}): CommandCenterContribution {
|
||||
return {
|
||||
id: input.id,
|
||||
group: "workspace",
|
||||
groupRank: -1,
|
||||
rank: input.rank,
|
||||
keywords: input.keywords,
|
||||
visibility: input.visibility,
|
||||
run: input.run,
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: input.title,
|
||||
sectionTitle: input.source.labels.section,
|
||||
icon: input.icon,
|
||||
shortcutKeys: input.shortcutKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCreationContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
@@ -579,150 +511,20 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
...buildPanelContributions(source),
|
||||
...buildActiveTabContributions(source),
|
||||
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||
buildQueryAction(source, {
|
||||
id: "explorer-sidebar:toggle",
|
||||
rank: 70,
|
||||
title: source.labels.toggleExplorerSidebar,
|
||||
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
|
||||
icon: source.icons.explorerSidebar,
|
||||
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
|
||||
action: { id: "sidebar.toggle.right", scope: "workspace" },
|
||||
}),
|
||||
];
|
||||
|
||||
const primary = source.gitActions.primary;
|
||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||
if (action.id === primary?.id) continue;
|
||||
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||
}
|
||||
|
||||
if (source.capabilities.canPin) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:pin",
|
||||
rank: 20,
|
||||
title: source.isPinned ? source.labels.unpin : source.labels.pin,
|
||||
keywords: ["pin", "unpin", "favorite", "sticky"],
|
||||
icon: source.isPinned ? source.icons.unpin : source.icons.pin,
|
||||
shortcutKeys: source.shortcuts.pinWorkspace,
|
||||
action: { id: "workspace.pin", scope: "sidebar" },
|
||||
visibility: "always",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:rename",
|
||||
rank: 21,
|
||||
title: source.labels.rename,
|
||||
keywords: ["rename", "title", "name", "label"],
|
||||
icon: source.icons.rename,
|
||||
action: { id: "workspace.rename", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
buildWorkspaceCallback({
|
||||
source,
|
||||
id: "workspace:copy-path",
|
||||
rank: 22,
|
||||
title: source.labels.copyPath,
|
||||
keywords: ["copy", "path", "directory", "folder", "cwd"],
|
||||
icon: source.icons.copyPath,
|
||||
run: source.copyPath,
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
|
||||
// Omitted rather than present-and-failing: a non-git workspace has nothing to copy.
|
||||
if (source.currentBranch) {
|
||||
contributions.push(
|
||||
buildWorkspaceCallback({
|
||||
source,
|
||||
id: "workspace:copy-branch-name",
|
||||
rank: 23,
|
||||
title: source.labels.copyBranchName,
|
||||
keywords: ["copy", "branch", "git", source.currentBranch],
|
||||
icon: source.icons.copyBranchName,
|
||||
run: source.copyBranchName,
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (source.capabilities.canShowSetup) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:show-setup",
|
||||
rank: 24,
|
||||
title: source.labels.showSetup,
|
||||
keywords: ["setup", "provision", "install", "bootstrap"],
|
||||
icon: source.icons.showSetup,
|
||||
action: { id: "workspace.setup.show", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx:
|
||||
// their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a
|
||||
// global registration would list two entries that silently no-op on /settings, /sessions,
|
||||
// /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why
|
||||
// the three toggles render in two non-adjacent sections. Don't "tidy" them back together.
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:toggle-explorer-sidebar",
|
||||
rank: 25,
|
||||
title: source.labels.toggleExplorerSidebar,
|
||||
keywords: ["toggle", "sidebar", "explorer", "files", "changes"],
|
||||
icon: source.icons.explorerSidebar,
|
||||
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
|
||||
action: { id: "sidebar.toggle.right", scope: "sidebar" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
|
||||
// `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as
|
||||
// `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry
|
||||
// where that function is skipped — otherwise the palette lists "Toggle focus mode" twice.
|
||||
if (!source.capabilities.canSplitPanes) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:toggle-focus-mode",
|
||||
rank: 26,
|
||||
title: source.labels.toggleFocusMode,
|
||||
keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"],
|
||||
icon: source.icons.toggleFocusMode,
|
||||
shortcutKeys: source.shortcuts.toggleFocusMode,
|
||||
action: { id: "workspace.focus.toggle", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
contributions.push(...buildLabelContributions(source));
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* One choice per catalog label, ticked when the current workspace carries it. Selecting toggles
|
||||
* assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a
|
||||
* multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing.
|
||||
*/
|
||||
function buildLabelContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
if (!source.labelCatalog) return [];
|
||||
return source.labelCatalog.map((choice, index) => ({
|
||||
id: `workspace:label:${choice.name}`,
|
||||
group: "workspace",
|
||||
groupRank: -1,
|
||||
rank: 30 + index,
|
||||
keywords: ["label", "tag", choice.name],
|
||||
visibility: "query" as const,
|
||||
run: () => source.toggleLabel(choice.name, !choice.assigned),
|
||||
presentation: {
|
||||
kind: "choice" as const,
|
||||
path: [source.labels.labelsGroup, choice.name] as const,
|
||||
icon: choice.icon,
|
||||
selected: choice.assigned,
|
||||
testId: `command-center-workspace-label-${choice.name}`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
@@ -8,16 +8,12 @@ import {
|
||||
Copy,
|
||||
Files,
|
||||
Focus,
|
||||
GitBranch,
|
||||
GitCompareArrows,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
ListChecks,
|
||||
Move,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
SquarePen,
|
||||
@@ -29,35 +25,23 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import {
|
||||
buildWorkspaceLabelPickerRows,
|
||||
useWorkspaceLabelProjection,
|
||||
workspaceLabelErrorMessage,
|
||||
workspaceLabels,
|
||||
} from "@/workspace-labels";
|
||||
import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { getCommandCenterIcon } from "./icon";
|
||||
import type { CommandCenterIcon } from "./contributions";
|
||||
import { useCommandCenterActions } from "./provider";
|
||||
import {
|
||||
buildWorkspaceCommandCenterContributions,
|
||||
type WorkspaceCommandCenterLabelChoice,
|
||||
type WorkspaceCommandCenterShortcuts,
|
||||
} from "./workspace-contributions";
|
||||
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
|
||||
@@ -81,13 +65,6 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
||||
moveTab: getCommandCenterIcon(Move),
|
||||
focusMode: getCommandCenterIcon(ArrowDownToLine),
|
||||
explorerSidebar: getCommandCenterIcon(PanelRight),
|
||||
// Workspace management action icons
|
||||
copyPath: getCommandCenterIcon(Copy),
|
||||
copyBranchName: getCommandCenterIcon(GitBranch),
|
||||
pin: getCommandCenterIcon(Pin),
|
||||
unpin: getCommandCenterIcon(PinOff),
|
||||
showSetup: getCommandCenterIcon(ListChecks),
|
||||
toggleFocusMode: getCommandCenterIcon(Focus),
|
||||
};
|
||||
|
||||
const OPEN_PANEL_LABEL_KEYS = {
|
||||
@@ -109,58 +86,6 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
|
||||
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
|
||||
}
|
||||
|
||||
/**
|
||||
* The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to
|
||||
* keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the
|
||||
* group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for
|
||||
* the sidebar's own picker.
|
||||
*/
|
||||
function useWorkspaceLabelCatalog(
|
||||
serverId: string | null,
|
||||
fields: { id: string; labels: readonly string[] } | null,
|
||||
): {
|
||||
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
|
||||
toggleLabel: (name: string, assigned: boolean) => void;
|
||||
} {
|
||||
const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection(
|
||||
serverId ?? undefined,
|
||||
);
|
||||
const toast = useToast();
|
||||
const assignedLabels = fields?.labels;
|
||||
const labelCatalog = useMemo<readonly WorkspaceCommandCenterLabelChoice[] | null>(
|
||||
() =>
|
||||
labelHost?.status === "online" && assignedLabels
|
||||
? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map(
|
||||
(row) => ({
|
||||
name: row.name,
|
||||
assigned: row.assigned,
|
||||
icon: getLabelCommandCenterIcon(row.color),
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
[assignedLabels, labelDefinitions, labelHost?.status],
|
||||
);
|
||||
const toggleLabel = useCallback(
|
||||
async (name: string, assigned: boolean) => {
|
||||
if (!serverId || !fields) return;
|
||||
const definition = labelDefinitions.find((label) => label.name === name);
|
||||
if (!definition) return;
|
||||
try {
|
||||
await workspaceLabels.setAssignment({
|
||||
serverId,
|
||||
workspaceId: fields.id,
|
||||
label: definition,
|
||||
assigned,
|
||||
});
|
||||
} catch (cause) {
|
||||
toast.error(workspaceLabelErrorMessage(cause));
|
||||
}
|
||||
},
|
||||
[fields, labelDefinitions, serverId, toast],
|
||||
);
|
||||
return { labelCatalog, toggleLabel };
|
||||
}
|
||||
|
||||
export function useWorkspaceCommandCenterActions(): void {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
@@ -179,29 +104,8 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||
const activeTabKind =
|
||||
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||
// One narrow projection for the workspace management contribution fields. The registry's snapshot
|
||||
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
|
||||
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
|
||||
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
|
||||
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
|
||||
id: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory ?? null,
|
||||
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
|
||||
pinnedAt: workspace.pinnedAt ?? null,
|
||||
labels: workspace.labels ?? [],
|
||||
}));
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const currentBranch = fields?.currentBranch ?? null;
|
||||
const isPinned = fields?.pinnedAt != null;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const canPin = useHostFeature(serverId, "workspacePinning");
|
||||
const persistenceKey =
|
||||
serverId && fields
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
|
||||
: null;
|
||||
const canShowSetup = useWorkspaceSetupStore((state) =>
|
||||
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
|
||||
);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
serverId: serverId ?? "",
|
||||
@@ -209,27 +113,6 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
icons: GIT_ACTION_ICONS,
|
||||
});
|
||||
const runGitAction = useGitActionRunner();
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
|
||||
const copyPath = useCallback(() => {
|
||||
if (!fields) return;
|
||||
clipboard.copyPath({
|
||||
workspaceId: fields.id,
|
||||
workspaceDirectory: fields.workspaceDirectory,
|
||||
currentBranch: fields.currentBranch,
|
||||
});
|
||||
}, [clipboard, fields]);
|
||||
|
||||
const copyBranchName = useCallback(() => {
|
||||
if (!fields) return;
|
||||
clipboard.copyBranchName({
|
||||
workspaceId: fields.id,
|
||||
workspaceDirectory: fields.workspaceDirectory,
|
||||
currentBranch: fields.currentBranch,
|
||||
});
|
||||
}, [clipboard, fields]);
|
||||
|
||||
const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
@@ -269,14 +152,6 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
closePane: t("settings.shortcuts.help.closePane"),
|
||||
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
|
||||
// Workspace management labels
|
||||
rename: t("sidebar.workspace.actions.rename"),
|
||||
copyPath: t("workspace.header.actions.copyPath"),
|
||||
copyBranchName: t("workspace.header.actions.copyBranchName"),
|
||||
pin: t("sidebar.workspace.actions.pin"),
|
||||
unpin: t("sidebar.workspace.actions.unpin"),
|
||||
showSetup: t("workspace.header.actions.showSetup"),
|
||||
labelsGroup: t("workspaceLabels.title"),
|
||||
},
|
||||
icons: {
|
||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||
@@ -287,43 +162,27 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||
canOpenBrowserTabs: getIsElectron(),
|
||||
isGit,
|
||||
canPin,
|
||||
canShowSetup,
|
||||
},
|
||||
activeTabKind,
|
||||
activeTabIndex,
|
||||
activeTabCount: focusedTabs.length,
|
||||
currentBranch,
|
||||
isPinned,
|
||||
labelCatalog,
|
||||
dispatch: (action) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
keyboardActionDispatcher.dispatch(action);
|
||||
},
|
||||
runGitAction,
|
||||
copyPath,
|
||||
copyBranchName,
|
||||
toggleLabel,
|
||||
}),
|
||||
[
|
||||
activeTabIndex,
|
||||
activeTabKind,
|
||||
canPin,
|
||||
canShowSetup,
|
||||
copyBranchName,
|
||||
copyPath,
|
||||
currentBranch,
|
||||
focusedTabs.length,
|
||||
gitActions,
|
||||
isCompact,
|
||||
isGit,
|
||||
isPinned,
|
||||
keyboardActionDispatcher,
|
||||
labelCatalog,
|
||||
overrides,
|
||||
runGitAction,
|
||||
t,
|
||||
toggleLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -31,10 +31,7 @@ export function resolveWorkspaceCommandCenterShortcuts({
|
||||
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
|
||||
closePane:
|
||||
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
|
||||
toggleFocusMode: resolveShortcutKeysForAction("toggle-focus", overrides, platform) ?? undefined,
|
||||
toggleExplorerSidebar:
|
||||
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
|
||||
// Workspace management shortcuts
|
||||
pinWorkspace: resolveShortcutKeysForAction("pin-workspace", overrides, platform) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedQrCode = withUnistyles(QrCode);
|
||||
const ThemedLink2 = withUnistyles(Link2);
|
||||
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
const foregroundIconMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
@@ -45,7 +37,6 @@ export interface AddHostMethodModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDirectConnection: () => void;
|
||||
onRemoteSsh: () => void;
|
||||
onScanQr: () => void;
|
||||
onPasteLink: () => void;
|
||||
}
|
||||
@@ -54,10 +45,10 @@ export function AddHostMethodModal({
|
||||
visible,
|
||||
onClose,
|
||||
onDirectConnection,
|
||||
onRemoteSsh,
|
||||
onScanQr,
|
||||
onPasteLink,
|
||||
}: AddHostMethodModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
|
||||
|
||||
@@ -69,10 +60,6 @@ export function AddHostMethodModal({
|
||||
onScanQr();
|
||||
}, [onScanQr]);
|
||||
|
||||
const handleRemoteSsh = useCallback(() => {
|
||||
onRemoteSsh();
|
||||
}, [onRemoteSsh]);
|
||||
|
||||
const handlePaste = useCallback(() => {
|
||||
onPasteLink();
|
||||
}, [onPasteLink]);
|
||||
@@ -91,7 +78,7 @@ export function AddHostMethodModal({
|
||||
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<ThemedLink2 size={18} uniProps={foregroundIconMapping} />
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
@@ -100,24 +87,6 @@ export function AddHostMethodModal({
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{isElectronRuntime() ? (
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleRemoteSsh}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.connectionMethods.remoteSsh.title")}
|
||||
testID="add-host-method-remote-ssh"
|
||||
>
|
||||
<ThemedTerminal size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.remoteSsh.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{isNative && !isFdroidBuild ? (
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
@@ -125,7 +94,7 @@ export function AddHostMethodModal({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
|
||||
>
|
||||
<ThemedQrCode size={18} uniProps={foregroundIconMapping} />
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
@@ -142,7 +111,7 @@ export function AddHostMethodModal({
|
||||
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ThemedClipboardPaste size={18} uniProps={foregroundIconMapping} />
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
export interface AddRemoteSshHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
profile: HostProfile;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
isNewHost: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function AddRemoteSshHostModal({
|
||||
visible,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: AddRemoteSshHostModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||
const targetRef = useRef("");
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
targetRef.current = "";
|
||||
inputRef.current?.replaceText("");
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clear();
|
||||
onClose();
|
||||
}, [clear, isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clear();
|
||||
(onCancel ?? onClose)();
|
||||
}, [clear, isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
const rawTarget = targetRef.current.trim();
|
||||
if (!rawTarget) {
|
||||
setErrorMessage(t("pairing.remoteSsh.errors.targetRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
let target: ReturnType<typeof parseSshTransportUri>;
|
||||
try {
|
||||
target = parseSshTransportUri(rawTarget);
|
||||
} catch {
|
||||
setErrorMessage(t("pairing.remoteSsh.errors.invalidTarget"));
|
||||
return;
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof probeAndUpsertRemoteSshConnection>>;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
result = await probeAndUpsertRemoteSshConnection(target);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof DaemonConnectionTestError
|
||||
? t("pairing.remoteSsh.errors.failedToConnect", { detail: error.message })
|
||||
: t("common.errors.unableToSave");
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
clear();
|
||||
onClose();
|
||||
onSaved?.({
|
||||
...result,
|
||||
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
|
||||
});
|
||||
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
}, []);
|
||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="add-remote-ssh-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>{t("pairing.remoteSsh.helper")}</Text>
|
||||
<Field
|
||||
label={t("pairing.remoteSsh.fields.target")}
|
||||
error={errorMessage}
|
||||
testID="remote-ssh-target"
|
||||
>
|
||||
<FormTextInput
|
||||
ref={inputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-target-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.target")}
|
||||
initialValue=""
|
||||
onChangeText={handleTargetChange}
|
||||
placeholder="ssh://user@host"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
variant="secondary"
|
||||
onPress={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("pairing.remoteSsh.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSaving}
|
||||
leftIcon={ThemedTerminal}
|
||||
testID="remote-ssh-submit"
|
||||
>
|
||||
{isSaving
|
||||
? t("pairing.remoteSsh.actions.connecting")
|
||||
: t("pairing.remoteSsh.actions.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT,
|
||||
capAssistantMessageForRender,
|
||||
getUtf8ByteLength,
|
||||
} from "./assistant-message-render-limit";
|
||||
|
||||
describe("assistant message render limit", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("leaves messages at the limit unchanged", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
|
||||
});
|
||||
|
||||
it("caps oversized messages before markdown rendering", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not split a grapheme cluster at the boundary", () => {
|
||||
const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - 1);
|
||||
|
||||
expect(capAssistantMessageForRender(`${prefix}étail`)).toEqual({
|
||||
text: prefix,
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts the complete message size in UTF-8 bytes", () => {
|
||||
expect(getUtf8ByteLength("aé😀")).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined });
|
||||
const { capAssistantMessageForRender: capWithoutSegmenter } =
|
||||
await import("./assistant-message-render-limit");
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capWithoutSegmenter(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { clampToSafeRevealBoundary, isTextRevealPacingSupported } from "@/agent-stream/text-reveal";
|
||||
|
||||
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
|
||||
|
||||
export interface CappedAssistantMessage {
|
||||
text: string;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
export function getUtf8ByteLength(message: string): number {
|
||||
let bytes = 0;
|
||||
|
||||
for (let index = 0; index < message.length; index += 1) {
|
||||
const codeUnit = message.charCodeAt(index);
|
||||
if (codeUnit < 0x80) {
|
||||
bytes += 1;
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2;
|
||||
} else if (
|
||||
codeUnit >= 0xd800 &&
|
||||
codeUnit <= 0xdbff &&
|
||||
index + 1 < message.length &&
|
||||
message.charCodeAt(index + 1) >= 0xdc00 &&
|
||||
message.charCodeAt(index + 1) <= 0xdfff
|
||||
) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
|
||||
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
|
||||
return { text: message, capped: false };
|
||||
}
|
||||
|
||||
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
|
||||
if (isTextRevealPacingSupported()) {
|
||||
end = clampToSafeRevealBoundary(message, end);
|
||||
} else {
|
||||
const finalCodeUnit = message.charCodeAt(end - 1);
|
||||
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: message.slice(0, end), capped: true };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
import { AttachmentLightbox } from "./attachment-lightbox";
|
||||
|
||||
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
const hoistedTheme = {
|
||||
@@ -42,11 +42,6 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
),
|
||||
};
|
||||
});
|
||||
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
|
||||
const assistantImageSource: ImageLightboxSource = {
|
||||
type: "uri",
|
||||
uri: "blob:assistant-image",
|
||||
};
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
@@ -75,11 +70,6 @@ vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
@@ -88,34 +78,16 @@ vi.mock("lucide-react-native", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/zoomable-viewport/image", () => ({
|
||||
ZoomableImage: (props: Record<string, unknown>) => {
|
||||
const actions = props.actions as Array<{
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}>;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": `${String(props.testID)}-image`,
|
||||
"data-source": props.uri,
|
||||
role: "img",
|
||||
},
|
||||
actions.map((action) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-label": action.label,
|
||||
"data-testid": action.testID,
|
||||
key: action.label,
|
||||
onClick: action.onPress,
|
||||
type: "button",
|
||||
},
|
||||
action.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.mock("expo-image", () => ({
|
||||
Image: (props: Record<string, unknown>) => {
|
||||
const source = props.source as { uri?: string } | string | undefined;
|
||||
const uri = typeof source === "string" ? source : source?.uri;
|
||||
return React.createElement("div", {
|
||||
"data-testid": props.testID,
|
||||
"data-source": uri,
|
||||
"data-style": JSON.stringify(props.style ?? null),
|
||||
role: "img",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -136,8 +108,6 @@ let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
dom.window.requestAnimationFrame = vi.fn(() => 1);
|
||||
dom.window.cancelAnimationFrame = vi.fn();
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
@@ -180,32 +150,42 @@ function queryByTestId(testID: string): HTMLElement | null {
|
||||
}
|
||||
|
||||
describe("AttachmentLightbox", () => {
|
||||
it("renders nothing when the source is null", () => {
|
||||
render(<AttachmentLightbox source={null} onClose={vi.fn()} />);
|
||||
it("renders nothing when metadata is null", () => {
|
||||
render(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the image when metadata is provided", () => {
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||
|
||||
const image = queryByTestId("attachment-lightbox-image");
|
||||
expect(image).not.toBeNull();
|
||||
expect(image?.getAttribute("data-source")).toBe("blob:preview");
|
||||
});
|
||||
|
||||
it("renders a direct timeline image without resolving attachment storage", () => {
|
||||
render(<AttachmentLightbox source={assistantImageSource} onClose={vi.fn()} />);
|
||||
it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => {
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||
|
||||
const image = queryByTestId("attachment-lightbox-image");
|
||||
expect(image?.getAttribute("data-source")).toBe("blob:assistant-image");
|
||||
expect(useAttachmentPreviewUrlMock).toHaveBeenLastCalledWith(null);
|
||||
const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as {
|
||||
position?: string;
|
||||
top?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
} | null;
|
||||
expect(style?.position).toBe("absolute");
|
||||
expect(style?.top).toBe(0);
|
||||
expect(style?.left).toBe(0);
|
||||
expect(style?.right).toBe(0);
|
||||
expect(style?.bottom).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onClose when the backdrop is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||
|
||||
const backdrop = queryByTestId("attachment-lightbox-backdrop");
|
||||
expect(backdrop).not.toBeNull();
|
||||
@@ -216,7 +196,7 @@ describe("AttachmentLightbox", () => {
|
||||
|
||||
it("calls onClose when the close button is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||
|
||||
const closeButton = document.querySelector(
|
||||
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
|
||||
@@ -229,7 +209,7 @@ describe("AttachmentLightbox", () => {
|
||||
|
||||
it("shows error text when the preview URL resolves to null", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue(null);
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").toContain("Couldn't load image");
|
||||
@@ -237,7 +217,7 @@ describe("AttachmentLightbox", () => {
|
||||
|
||||
it("closes on Escape key on web", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
|
||||
|
||||
@@ -1,88 +1,63 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
| { type: "uri"; uri: string; contentSize?: ViewportSize };
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
|
||||
interface AttachmentLightboxProps {
|
||||
source: ImageLightboxSource | null;
|
||||
metadata: AttachmentMetadata | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ModalRoot = isNative ? GestureHandlerRootView : View;
|
||||
const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const url = useAttachmentPreviewUrl(metadata);
|
||||
const [errored, setErrored] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id, url]);
|
||||
}, [metadata?.id]);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
return true;
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && source !== null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isWeb || !metadata) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
}, [metadata, onClose]);
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
const closeButtonRowStyle = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
styles.closeButtonRow,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
top: insets.top + theme.spacing[3],
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
[insets.top, theme.spacing],
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: X,
|
||||
label: t("message.attachments.closeImage"),
|
||||
onPress: onClose,
|
||||
testID: "attachment-lightbox-close",
|
||||
},
|
||||
],
|
||||
[onClose, t],
|
||||
const closeButtonStyle = useMemo(
|
||||
() => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }],
|
||||
[insets.right, theme.spacing],
|
||||
);
|
||||
|
||||
const handleImageError = useCallback(() => setErrored(true), []);
|
||||
const noopPress = useCallback(() => {}, []);
|
||||
const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]);
|
||||
|
||||
if (!source) {
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,47 +65,61 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
actions={actions}
|
||||
contentSize={contentSize}
|
||||
fit={LIGHTBOX_FIT}
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
testID="attachment-lightbox-image"
|
||||
source={imageSource}
|
||||
contentFit="contain"
|
||||
onError={handleImageError}
|
||||
onPressOutsideContent={onClose}
|
||||
style={styles.imageViewport}
|
||||
testID="attachment-lightbox"
|
||||
uri={url}
|
||||
style={imageFillStyle}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={closeButtonRowStyle}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</ModalRoot>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const imageFillStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
} as const;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
@@ -146,19 +135,42 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
closeButtonRow: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "flex-end",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
imageArea: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
imageViewport: {
|
||||
imagePressable: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
alignSelf: "center",
|
||||
maxWidth: 960,
|
||||
maxHeight: 640,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -8,8 +8,6 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { ModelBrowser, ModelProviderGlyph, useModelBrowser } from "@/components/model-browser";
|
||||
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -89,8 +87,6 @@ export function CombinedModelSelector({
|
||||
toolbar,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const modelBrowserScrolling = resolveModelBrowserScrolling({ isNative, isCompact });
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
||||
@@ -202,7 +198,7 @@ export function CombinedModelSelector({
|
||||
onEditProfile={onEditProfile ? handleEditProfile : undefined}
|
||||
onRetryProvider={onRetryProvider}
|
||||
isRetryingProvider={isRetryingProvider}
|
||||
scrolling={modelBrowserScrolling}
|
||||
scrolling={isWeb ? "independent" : "sheet"}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
NativeExplorerSidebarDock,
|
||||
} from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
@@ -52,7 +51,7 @@ function useActiveCompactExplorerSidebarModel(
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
@@ -68,32 +67,32 @@ function useActiveCompactExplorerSidebarModel(
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerActive) {
|
||||
if (enabled && isExplorerOpen) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerActive) {
|
||||
if (!isExplorerOpen) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -82,7 +81,7 @@ export function CompactExplorerSidebar({
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isActive = useIsMobilePanelActive("file-explorer");
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
@@ -100,11 +99,11 @@ export function CompactExplorerSidebar({
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen: isActive,
|
||||
isOpen,
|
||||
});
|
||||
showMobileAgent();
|
||||
},
|
||||
[isActive, showMobileAgent],
|
||||
[isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
@@ -128,7 +127,7 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isActive}>
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
@@ -142,7 +141,7 @@ export function CompactExplorerSidebar({
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isActive}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatDiffCount } from "@/git/file-header-presentation";
|
||||
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
@@ -8,6 +7,15 @@ interface DiffStatProps {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row} testID={testID}>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./keyboard-dock.native";
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardPaddingStyle = useAnimatedStyle(() => ({
|
||||
paddingBottom: shift.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, keyboardPaddingStyle]} {...props}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock(props: KeyboardDockProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildNewWorkspaceRoute,
|
||||
@@ -120,7 +121,6 @@ interface SidebarLabels {
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
active: boolean;
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
closeSidebar: () => void;
|
||||
@@ -273,7 +273,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
@@ -605,7 +604,6 @@ function SidebarFooter({
|
||||
}
|
||||
|
||||
function MobileSidebar({
|
||||
active,
|
||||
theme,
|
||||
workspaceGroups,
|
||||
projectIconTargets,
|
||||
@@ -640,6 +638,7 @@ function MobileSidebar({
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
closeSidebar();
|
||||
@@ -738,7 +737,7 @@ function MobileSidebar({
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { Code, Workflow } from "lucide-react-native";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
import type { MermaidRenderRequest } from "./render-model";
|
||||
@@ -14,9 +21,13 @@ import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
import { getDiagramBoxStyle } from "./presentation";
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
interface MermaidIframeRuntimeProps {
|
||||
request: MermaidRenderRequest | null;
|
||||
height: React.CSSProperties["height"];
|
||||
height: number;
|
||||
onRendered: (message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
@@ -50,7 +61,9 @@ function MermaidIframeRuntime({
|
||||
|
||||
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (!current || !target) return;
|
||||
if (!current || !target) {
|
||||
return;
|
||||
}
|
||||
const message: MermaidRuntimeRenderMessage = {
|
||||
type: "render",
|
||||
revision: current.revision,
|
||||
@@ -67,9 +80,13 @@ function MermaidIframeRuntime({
|
||||
|
||||
useEffect(() => {
|
||||
function receiveMessage(event: MessageEvent): void {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (!message) return;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "bridgeReady") {
|
||||
sendRequest(driverRef.current?.ready() ?? null);
|
||||
return;
|
||||
@@ -99,9 +116,8 @@ function MermaidIframeRuntime({
|
||||
);
|
||||
}
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 8;
|
||||
|
||||
function MermaidFenceHostImpl({
|
||||
code,
|
||||
@@ -117,9 +133,6 @@ function MermaidFenceHostImpl({
|
||||
colorScheme,
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
@@ -140,18 +153,152 @@ function MermaidFenceHostImpl({
|
||||
);
|
||||
const visible = state.visible;
|
||||
const canShowDiagram = visible !== null && hasRuntimeContent;
|
||||
const diagramVisible = canShowDiagram && !showSource;
|
||||
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: Code,
|
||||
label: t("message.diagram.viewSource"),
|
||||
onPress: showSourcePress,
|
||||
},
|
||||
],
|
||||
[showSourcePress, t],
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const controlsRegionRef = useRef<HTMLDivElement | null>(null);
|
||||
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
|
||||
const applyTransformToElement = useCallback(
|
||||
(element: HTMLDivElement, transform: { x: number; y: number; scale: number }) => {
|
||||
const { x, y, scale } = transform;
|
||||
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const applyTransform = useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
applyTransformToElement(contentRef.current, transformRef.current);
|
||||
}
|
||||
}, [applyTransformToElement]);
|
||||
const setContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
if (node) {
|
||||
applyTransformToElement(node, transformRef.current);
|
||||
}
|
||||
},
|
||||
[applyTransformToElement],
|
||||
);
|
||||
const setControlsRegionRef = useCallback((node: unknown) => {
|
||||
controlsRegionRef.current = node instanceof HTMLDivElement ? node : null;
|
||||
}, []);
|
||||
const hasFocusedControls = useCallback(() => {
|
||||
const region = controlsRegionRef.current;
|
||||
const active = document.activeElement;
|
||||
return Boolean(region && active instanceof Node && region.contains(active));
|
||||
}, []);
|
||||
const [isFocusWithin, setIsFocusWithin] = useState(false);
|
||||
const handleFocusWithin = useCallback(() => setIsFocusWithin(true), []);
|
||||
const handleBlurWithin = useCallback(() => {
|
||||
window.requestAnimationFrame(() => setIsFocusWithin(hasFocusedControls()));
|
||||
}, [hasFocusedControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || !visible) {
|
||||
return;
|
||||
}
|
||||
const activeViewport = viewport;
|
||||
function zoomWithWheel(event: WheelEvent): void {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const transform = transformRef.current;
|
||||
const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
|
||||
const rect = activeViewport.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
transform.x = x - ((x - transform.x) / transform.scale) * scale;
|
||||
transform.y = y - ((y - transform.y) / transform.scale) * scale;
|
||||
transform.scale = scale;
|
||||
applyTransform();
|
||||
}
|
||||
viewport.addEventListener("wheel", zoomWithWheel, { passive: false });
|
||||
return () => viewport.removeEventListener("wheel", zoomWithWheel);
|
||||
}, [applyTransform, visible]);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || event.pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
const transform = transformRef.current;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: transform.x,
|
||||
originY: transform.y,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}, []);
|
||||
const onPointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || event.pointerId !== drag.pointerId) {
|
||||
return;
|
||||
}
|
||||
transformRef.current.x = drag.originX + event.clientX - drag.startX;
|
||||
transformRef.current.y = drag.originY + event.clientY - drag.startY;
|
||||
applyTransform();
|
||||
},
|
||||
[applyTransform],
|
||||
);
|
||||
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) {
|
||||
dragRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const resetTransform = useCallback(() => {
|
||||
transformRef.current = { x: 0, y: 0, scale: 1 };
|
||||
applyTransform();
|
||||
}, [applyTransform]);
|
||||
const zoomBy = useCallback(
|
||||
(factor: number) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const x = rect.width / 2;
|
||||
const y = rect.height / 2;
|
||||
const transform = transformRef.current;
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
|
||||
transform.x = x - ((x - transform.x) / transform.scale) * scale;
|
||||
transform.y = y - ((y - transform.y) / transform.scale) * scale;
|
||||
transform.scale = scale;
|
||||
applyTransform();
|
||||
},
|
||||
[applyTransform],
|
||||
);
|
||||
const zoomInPress = useCallback(() => zoomBy(1.25), [zoomBy]);
|
||||
const zoomOutPress = useCallback(() => zoomBy(0.8), [zoomBy]);
|
||||
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const controlsVisible = isHovered || isCompact || isFocusWithin;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSource && contentRef.current) {
|
||||
applyTransformToElement(contentRef.current, transformRef.current);
|
||||
}
|
||||
}, [applyTransformToElement, showSource]);
|
||||
|
||||
const sourceView = useMemo(() => {
|
||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||
const margins: ViewStyle = {
|
||||
@@ -159,90 +306,182 @@ function MermaidFenceHostImpl({
|
||||
marginBottom: marginBottom ?? marginVertical,
|
||||
};
|
||||
const text: TextStyle = sourceTextStyle;
|
||||
return { container: [margins, sourceContainerStyle], text };
|
||||
return {
|
||||
container: [margins, sourceContainerStyle],
|
||||
text,
|
||||
};
|
||||
}, [textStyle]);
|
||||
const diagramStyle = useMemo(
|
||||
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
const diagramSize = useMemo(
|
||||
() => (visible ? { width: visible.width, height: visible.height } : MEASURING_SIZE),
|
||||
[visible],
|
||||
);
|
||||
const sourceVisible = !diagramVisible;
|
||||
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
|
||||
const sourceTextStyle = showSource ? sourceView.text : textStyle;
|
||||
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
|
||||
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
|
||||
|
||||
const diagramVisible = canShowDiagram && !showSource;
|
||||
const sourceVisible = !canShowDiagram || showSource;
|
||||
let rootStyle: StyleProp<ViewStyle> = sourceContainerStyle;
|
||||
if (diagramVisible) {
|
||||
rootStyle = [diagramBoxStyle, containerStyle];
|
||||
} else if (showSource) {
|
||||
rootStyle = sourceView.container;
|
||||
}
|
||||
const sourceTextStyle = showSource ? sourceView.text : textStyle;
|
||||
|
||||
const runtime = (
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
height={runtimeHeight}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
ref={setControlsRegionRef}
|
||||
style={rootStyle}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onFocus={handleFocusWithin}
|
||||
onBlur={handleBlurWithin}
|
||||
>
|
||||
{sourceVisible ? (
|
||||
<View style={sourceContainer}>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role={diagramVisible ? "img" : undefined}
|
||||
aria-label={diagramVisible ? t("message.diagram.diagram") : undefined}
|
||||
aria-hidden={!diagramVisible}
|
||||
style={diagramVisible ? viewportDomStyle : measuringRuntimeStyle}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onDoubleClick={resetTransform}
|
||||
>
|
||||
<div ref={setContentRef} style={contentDomStyle}>
|
||||
{runtime}
|
||||
</div>
|
||||
</div>
|
||||
{diagramVisible ? (
|
||||
<View style={controlStyles.cluster}>
|
||||
<DiagramControlButton
|
||||
icon={ZoomIn}
|
||||
label={t("message.diagram.zoomIn")}
|
||||
onPress={zoomInPress}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
<DiagramControlButton
|
||||
icon={ZoomOut}
|
||||
label={t("message.diagram.zoomOut")}
|
||||
onPress={zoomOutPress}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
<DiagramControlButton
|
||||
icon={Scan}
|
||||
label={t("message.diagram.resetZoom")}
|
||||
onPress={resetTransform}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
<DiagramControlButton
|
||||
icon={Code}
|
||||
label={t("message.diagram.viewSource")}
|
||||
onPress={showSourcePress}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
{showSource && canShowDiagram ? (
|
||||
<Pressable
|
||||
accessibilityLabel={t("message.diagram.viewDiagram")}
|
||||
accessibilityRole="button"
|
||||
hitSlop={4}
|
||||
onPress={showDiagramPress}
|
||||
style={controlStyles.sourceButton}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Workflow
|
||||
size={14}
|
||||
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={diagramSize}
|
||||
style={viewportStyle}
|
||||
testID="mermaid-viewport"
|
||||
wheelActivation="modifier"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
height="100%"
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
</>
|
||||
{showSource && canShowDiagram ? (
|
||||
<View style={[controlStyles.cluster, controlStyles.clusterSourceOffset]}>
|
||||
<DiagramControlButton
|
||||
icon={Workflow}
|
||||
label={t("message.diagram.viewDiagram")}
|
||||
onPress={showDiagramPress}
|
||||
visible={controlsVisible}
|
||||
plain
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const MEASURING_SIZE = { width: 240, height: 240 };
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
|
||||
const measuringStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 240,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
interface DiagramControlButtonProps {
|
||||
icon: ComponentType<{ size?: number; color?: string }>;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
plain?: boolean;
|
||||
}
|
||||
|
||||
const DiagramControlButton = React.memo(function DiagramControlButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onPress,
|
||||
visible,
|
||||
plain = false,
|
||||
}: DiagramControlButtonProps) {
|
||||
const pillStyle = visible ? controlStyles.button : controlStyles.buttonHidden;
|
||||
const plainStyle = visible ? controlStyles.plainButton : controlStyles.plainButtonHidden;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={plain ? plainStyle : pillStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
hitSlop={4}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={14}
|
||||
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const controlStyles = StyleSheet.create((theme) => ({
|
||||
sourceButton: {
|
||||
cluster: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
padding: theme.spacing[1],
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
clusterSourceOffset: { right: 40 },
|
||||
button: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
buttonHidden: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
plainButton: { padding: theme.spacing[1], opacity: 1, pointerEvents: "auto" },
|
||||
plainButtonHidden: { padding: theme.spacing[1], opacity: 0, pointerEvents: "none" },
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
iconHovered: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
|
||||
const viewportDomStyle: React.CSSProperties = { cursor: "grab", userSelect: "none" };
|
||||
const contentDomStyle: React.CSSProperties = { transformOrigin: "0 0" };
|
||||
const measuringRuntimeStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
};
|
||||
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
|
||||
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
CircleDot,
|
||||
Copy,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
@@ -102,7 +103,7 @@ import {
|
||||
AttachmentLabel,
|
||||
AttachmentThumbnail,
|
||||
} from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
@@ -116,7 +117,6 @@ import {
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
} from "@/assistant-selection-copy/markup";
|
||||
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
@@ -441,10 +441,6 @@ export const UserMessage = memo(function UserMessage({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
|
||||
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(
|
||||
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
|
||||
[lightboxMetadata],
|
||||
);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
@@ -570,7 +566,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -578,7 +574,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number | null;
|
||||
durationMs?: number;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -616,8 +612,9 @@ const TIMESTAMP_REVEAL_MS = 3000;
|
||||
|
||||
/**
|
||||
* Footer rendered next to the copy button at the end of an assistant turn.
|
||||
* Shows the turn duration and swaps to the end timestamp when both are known.
|
||||
* A turn without a visible start shows its end timestamp directly.
|
||||
* Always shows the turn duration; swaps to the end timestamp on hover (web)
|
||||
* or tap (native). The hidden sizer keeps the label width stable while the
|
||||
* visible text swaps.
|
||||
*/
|
||||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
@@ -639,10 +636,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
}, []);
|
||||
|
||||
const durationLabel = useMemo(
|
||||
() =>
|
||||
durationMs !== undefined && durationMs !== null
|
||||
? `Worked for ${formatDuration(durationMs)}`
|
||||
: "",
|
||||
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
|
||||
[durationMs],
|
||||
);
|
||||
const timestampLabel = useMemo(
|
||||
@@ -650,8 +644,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
[completedAt],
|
||||
);
|
||||
|
||||
const primaryLabel = durationLabel || timestampLabel;
|
||||
const canSwap = Boolean(durationLabel && timestampLabel);
|
||||
const canSwap = Boolean(timestampLabel);
|
||||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||||
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
@@ -682,22 +675,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
{durationLabel ? (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
||||
>
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
container width is stable across hover transitions. */}
|
||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||||
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
|
||||
</Text>
|
||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||
{showTimestamp ? timestampLabel : primaryLabel}
|
||||
{showTimestamp ? timestampLabel : durationLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -767,13 +760,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerCompactBottom: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
cappedNotice: {
|
||||
marginTop: theme.spacing[3],
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontStyle: "italic",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
imageFrame: {
|
||||
width: "100%",
|
||||
minHeight: 160,
|
||||
@@ -830,10 +816,6 @@ function AssistantMarkdownImage({
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => ({
|
||||
marginTop: hasLeadingContent ? 16 : 0,
|
||||
@@ -866,14 +848,6 @@ function AssistantMarkdownImage({
|
||||
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||
if (!viewerOpen || !imageUri) return null;
|
||||
return {
|
||||
type: "uri",
|
||||
uri: imageUri,
|
||||
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||
};
|
||||
}, [aspectRatio, imageUri, viewerOpen]);
|
||||
|
||||
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
@@ -903,34 +877,21 @@ function AssistantMarkdownImage({
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
disabled={image.status !== "loaded"}
|
||||
onPress={openViewer}
|
||||
style={surfaceStyle}
|
||||
>
|
||||
<View
|
||||
<View style={surfaceStyle} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={assistantMessageStylesheet.image}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={alt}
|
||||
>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1499,16 +1460,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
spacing = "default",
|
||||
phase,
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
|
||||
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
||||
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
||||
const revealedMessage = useRevealedText(renderedMessage.text, phase);
|
||||
const fullMessageByteLength = useMemo(
|
||||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||||
[message, phase, renderedMessage.capped],
|
||||
);
|
||||
const revealedMessage = useRevealedText(message, phase);
|
||||
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
@@ -1987,14 +1942,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
/>
|
||||
</AssistantMessageBlockContainer>
|
||||
))}
|
||||
{fullMessageByteLength !== null ? (
|
||||
<Text
|
||||
testID="assistant-message-capped-notice"
|
||||
style={assistantMessageStylesheet.cappedNotice}
|
||||
>
|
||||
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -2314,6 +2261,8 @@ function taskActivityIcon(activity: TaskActivity) {
|
||||
return CircleDot;
|
||||
case "completed":
|
||||
return Check;
|
||||
case "reopened":
|
||||
return RotateCcw;
|
||||
default:
|
||||
return CheckSquare;
|
||||
}
|
||||
@@ -3171,7 +3120,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!shouldRenderInline) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
@@ -3185,7 +3133,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
@@ -3226,7 +3173,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
if (!shouldRenderInline) return null;
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={effectiveDetail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={maxDetailHeight}
|
||||
@@ -3235,7 +3181,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
);
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toolName,
|
||||
effectiveDetail,
|
||||
presentation.errorText,
|
||||
presentation.isLoadingDetails,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveInitialModelBrowserView,
|
||||
resolveModelBrowserAllView,
|
||||
groupProfilesByProviderModel,
|
||||
resolveModelBrowserScrolling,
|
||||
} from "./model-browser-view";
|
||||
|
||||
function provider(
|
||||
@@ -38,20 +37,6 @@ function modelRow(
|
||||
};
|
||||
}
|
||||
|
||||
describe("model browser scrolling", () => {
|
||||
it("participates in native compact bottom-sheet scrolling", () => {
|
||||
expect(resolveModelBrowserScrolling({ isNative: true, isCompact: true })).toBe("sheet");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ platform: "native wide", isNative: true, isCompact: false },
|
||||
{ platform: "compact web", isNative: false, isCompact: true },
|
||||
{ platform: "wide web", isNative: false, isCompact: false },
|
||||
])("owns scrolling on $platform surfaces", ({ isNative, isCompact }) => {
|
||||
expect(resolveModelBrowserScrolling({ isNative, isCompact })).toBe("independent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("model browser initial view", () => {
|
||||
const codex = provider("codex", "Codex");
|
||||
const pi = provider("pi", "Pi");
|
||||
|
||||
@@ -9,16 +9,6 @@ export type ModelBrowserView =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
export function resolveModelBrowserScrolling({
|
||||
isNative,
|
||||
isCompact,
|
||||
}: {
|
||||
isNative: boolean;
|
||||
isCompact: boolean;
|
||||
}): "sheet" | "independent" {
|
||||
return isNative && isCompact ? "sheet" : "independent";
|
||||
}
|
||||
|
||||
/** A profile's model reference; used to match profiles back to model rows. */
|
||||
export interface ModelProfileRef {
|
||||
provider: string;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -34,8 +36,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -134,6 +135,7 @@ import {
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import {
|
||||
getCurrentProjectRemoveReadiness,
|
||||
@@ -234,7 +236,7 @@ interface SidebarWorkspaceListProps {
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectHeaderRowProps {
|
||||
@@ -1261,14 +1263,42 @@ function WorkspaceRowWithMenu({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.currentBranch]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
@@ -1278,6 +1308,13 @@ function WorkspaceRowWithMenu({
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
@@ -1336,10 +1373,14 @@ function WorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
@@ -1540,7 +1581,7 @@ function ProjectBlock({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
hostBadgeByServerId,
|
||||
@@ -1565,7 +1606,7 @@ function ProjectBlock({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
creatingWorkspaceIds: ReadonlySet<string>;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -1745,7 +1786,7 @@ function ProjectBlock({
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canToggleWorkspaces ? (
|
||||
@@ -1829,7 +1870,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.isDragging === next.isDragging &&
|
||||
previous.dragHandleProps === next.dragHandleProps &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
|
||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||
areProjectBlockSelectionsEqual(previous, next)
|
||||
);
|
||||
@@ -1882,7 +1923,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
@@ -1959,7 +2000,7 @@ export function SidebarWorkspaceList({
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -1977,7 +2018,7 @@ export function SidebarWorkspaceList({
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
pathname={pathname}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
@@ -2010,7 +2051,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
}: {
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
@@ -2025,7 +2066,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
const pinnedWorkspaces = useMemo(
|
||||
@@ -2052,7 +2093,7 @@ function SidebarGroupedModeList({
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2072,7 +2113,7 @@ function ProjectModeList({
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
pathname,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2289,7 +2330,7 @@ function ProjectModeList({
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
@@ -2311,7 +2352,7 @@ function ProjectModeList({
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
projectIconByProjectViewKey,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -2388,7 +2429,7 @@ function ProjectModeList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
);
|
||||
@@ -2411,7 +2452,7 @@ function ProjectModeList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canTogglePinnedChats ? (
|
||||
|
||||
@@ -40,11 +40,14 @@ import {
|
||||
CircleX,
|
||||
} from "lucide-react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -126,7 +129,7 @@ interface StatusWorkspaceListProps {
|
||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||
sidebarFilterEmpty?: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
@@ -143,7 +146,7 @@ export function SidebarStatusWorkspaceList({
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
dragGestureHostPresented,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
@@ -214,7 +217,7 @@ export function SidebarStatusWorkspaceList({
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
gestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
@@ -623,17 +626,42 @@ function StatusWorkspaceRowWithMenu({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
@@ -691,10 +719,14 @@ function StatusWorkspaceRowWithMenu({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel="Rename"
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -2,20 +2,23 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
@@ -105,14 +108,42 @@ export function SidebarWorkspaceRow({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.currentBranch]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
@@ -122,6 +153,13 @@ export function SidebarWorkspaceRow({
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
@@ -168,10 +206,14 @@ export function SidebarWorkspaceRow({
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
workspace={workspace}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -11,10 +11,6 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
buildPaseoToolDetailSections,
|
||||
type PaseoToolDetailSection,
|
||||
} from "@getpaseo/protocol/paseo-tool-call-detail";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
@@ -31,7 +27,6 @@ const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
// ---- Content Component ----
|
||||
|
||||
interface ToolCallDetailsContentProps {
|
||||
toolName?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
maxHeight?: number;
|
||||
@@ -631,42 +626,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunct
|
||||
return out;
|
||||
}
|
||||
|
||||
function PaseoDetailSection({ section }: { section: PaseoToolDetailSection }) {
|
||||
return (
|
||||
<View style={styles.paseoSection}>
|
||||
<Text style={styles.paseoSectionTitle}>{section.title}</Text>
|
||||
{section.kind === "prose" ? (
|
||||
<Text selectable style={styles.paseoProse}>
|
||||
{section.text}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.paseoFields}>
|
||||
{section.fields.map((field) => (
|
||||
<View key={field.label} style={styles.paseoFieldRow}>
|
||||
<Text style={styles.paseoFieldLabel}>{field.label}</Text>
|
||||
<Text selectable style={styles.paseoFieldValue}>
|
||||
{field.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPaseoUnknownSections(
|
||||
toolName: string | undefined,
|
||||
detail: UnknownDetail,
|
||||
): ReactNode[] | null {
|
||||
if (!toolName) return null;
|
||||
const sections = buildPaseoToolDetailSections(toolName, detail.input, detail.output);
|
||||
if (!sections) return null;
|
||||
return sections.map((section) => <PaseoDetailSection key={section.title} section={section} />);
|
||||
}
|
||||
|
||||
function buildDetailSections(
|
||||
toolName: string | undefined,
|
||||
detail: ToolCallDetail | undefined,
|
||||
diffLines: DiffLine[] | undefined,
|
||||
ds: DetailStyles,
|
||||
@@ -741,7 +701,7 @@ function buildDetailSections(
|
||||
return [<ScrollablePlainTextSection key="plain-text" text={detail.text} ds={ds} />];
|
||||
}
|
||||
if (detail.type === "unknown") {
|
||||
return buildPaseoUnknownSections(toolName, detail) ?? buildUnknownSections(detail, ds, t);
|
||||
return buildUnknownSections(detail, ds, t);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -781,7 +741,6 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
toolName,
|
||||
detail,
|
||||
errorText,
|
||||
maxHeight,
|
||||
@@ -793,7 +752,7 @@ export function ToolCallDetailsContent({
|
||||
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
|
||||
const diffLines = useDiffLines(detail);
|
||||
|
||||
const sections: ReactNode[] = buildDetailSections(toolName, detail, diffLines, ds, t);
|
||||
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
|
||||
|
||||
if (errorText) {
|
||||
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
|
||||
@@ -837,46 +796,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
paseoSection: {
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
paseoSectionTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
paseoProse: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.5),
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
paseoFields: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
paseoFieldRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
paseoFieldLabel: {
|
||||
width: 120,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.5),
|
||||
},
|
||||
paseoFieldValue: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.5),
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
// ----- Types -----
|
||||
|
||||
export interface ToolCallSheetData {
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
@@ -156,14 +155,7 @@ interface ToolCallSheetContentProps {
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
toolName,
|
||||
displayName,
|
||||
detail,
|
||||
errorText,
|
||||
icon: IconComponent,
|
||||
showLoadingSkeleton,
|
||||
} = data;
|
||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -189,7 +181,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
{/* Content */}
|
||||
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={detail}
|
||||
errorText={errorText}
|
||||
fillAvailableHeight
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user