mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12daabe35 | ||
|
|
43d27328e4 | ||
|
|
b79b4bd5fc | ||
|
|
dac6cd141a | ||
|
|
c3ae54a1a2 | ||
|
|
956f6ea7ad | ||
|
|
bb53e7835b | ||
|
|
02f59b1a26 | ||
|
|
022c27a8a1 | ||
|
|
f660fcf2e5 | ||
|
|
50480d8c9d | ||
|
|
75b0eaf8a8 | ||
|
|
2caecdaae8 | ||
|
|
29883cb797 | ||
|
|
97bdbebd7f | ||
|
|
c527b203c4 | ||
|
|
602860625c | ||
|
|
c032df5b3d | ||
|
|
e1805ef542 | ||
|
|
a7f3bf7abb | ||
|
|
04624cb419 | ||
|
|
bc02cfd8cd | ||
|
|
32d6b18dbf | ||
|
|
cf4664ddf1 | ||
|
|
c3d37eba4b | ||
|
|
33224ace47 | ||
|
|
efa25ad3fe | ||
|
|
28a963795b | ||
|
|
3f7188bc1c | ||
|
|
b54c8e493a | ||
|
|
19a7d26349 | ||
|
|
2a0a0ff656 | ||
|
|
5d7afd59ae | ||
|
|
959da4acb8 | ||
|
|
9163aed3f1 | ||
|
|
c6b04f302a | ||
|
|
975dbd433b | ||
|
|
4dbd26c88c |
@@ -52,7 +52,7 @@ jobs:
|
||||
filters: .github/ci-paths.yml
|
||||
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
|
||||
+54
-40
@@ -1,67 +1,81 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.0-beta.3 - 2026-08-20
|
||||
## 0.5.1 - 2026-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||
|
||||
## 0.5.0-beta.2 - 2026-08-19
|
||||
## 0.5.0 - 2026-08-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
|
||||
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
|
||||
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
|
||||
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
|
||||
|
||||
## 0.5.0-beta.1 - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
|
||||
- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
||||
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
|
||||
- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287))
|
||||
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
|
||||
- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
|
||||
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
|
||||
- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605))
|
||||
- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735))
|
||||
- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
|
||||
- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
||||
- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682))
|
||||
- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685))
|
||||
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
|
||||
- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
|
||||
- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
|
||||
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
|
||||
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
|
||||
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
|
||||
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
|
||||
- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||
- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533))
|
||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
|
||||
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
|
||||
- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy))
|
||||
- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||
- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677))
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
||||
- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
|
||||
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
|
||||
- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
||||
- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447))
|
||||
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
||||
- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
|
||||
- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610))
|
||||
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
||||
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
|
||||
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
|
||||
- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
||||
- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
||||
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
|
||||
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
||||
- The text size setting now scales the whole interface
|
||||
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
||||
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
|
||||
- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532))
|
||||
- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
||||
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
|
||||
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
|
||||
- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734))
|
||||
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel
|
||||
- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev))
|
||||
- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||
- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev))
|
||||
- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||
- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
|
||||
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
|
||||
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
|
||||
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
|
||||
- Fixed resumed subagents splitting into separate rows
|
||||
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
|
||||
- Fixed workspace pull request status being lost on reload
|
||||
- Fixed hidden terminal tabs stopping output in the focused workspace
|
||||
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
||||
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
|
||||
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
|
||||
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
|
||||
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
||||
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
|
||||
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
||||
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
|
||||
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
|
||||
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
|
||||
- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736))
|
||||
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
|
||||
|
||||
## 0.4.0 - 2026-08-13
|
||||
|
||||
|
||||
@@ -21,45 +21,47 @@ This is an npm workspace monorepo:
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
|
||||
|
||||
| Doc | What's in it |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [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 |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
|
||||
### Writing docs
|
||||
|
||||
|
||||
+13
-2
@@ -35,7 +35,16 @@ be in flight.
|
||||
|
||||
### Cancellation
|
||||
|
||||
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
|
||||
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
|
||||
foreground turn can no longer run, including when the provider reports that it is already idle. It
|
||||
rejects only when the provider may still own the turn. Provider adapters translate native errors
|
||||
into that contract; lifecycle callers do not interpret provider-specific errors.
|
||||
|
||||
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
|
||||
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
|
||||
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
|
||||
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
|
||||
Accepting new work after an ambiguous interruption would create a split-brain session.
|
||||
|
||||
## Relationships
|
||||
|
||||
@@ -103,7 +112,9 @@ recoverable from History. Opening one pins its tab and renders the archived-agen
|
||||
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
||||
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
||||
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
|
||||
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
|
||||
resume repairs that drift through the provider's native unarchive hook; history resume does not.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent stream performance
|
||||
|
||||
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
|
||||
|
||||
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
|
||||
|
||||
## The pipeline
|
||||
|
||||
```
|
||||
provider deltas (every provider streams incrementally)
|
||||
→ AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
|
||||
```
|
||||
|
||||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
||||
|
||||
## Why the reveal is paced
|
||||
|
||||
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
|
||||
|
||||
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **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.
|
||||
|
||||
## Measuring
|
||||
|
||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
||||
|
||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
||||
|
||||
| | paint on arrival | paced |
|
||||
| -------------------------------- | ---------------- | ----- |
|
||||
| frames that advanced the text | 6% | 87% |
|
||||
| chars-per-frame CV | 4.11 | 1.86 |
|
||||
| gap between visible updates, p50 | 317ms | 17ms |
|
||||
| gap between visible updates, p95 | 383ms | 17ms |
|
||||
|
||||
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
|
||||
|
||||
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
|
||||
+34
-1
@@ -15,7 +15,9 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
|
||||
|
||||
## Version codes
|
||||
|
||||
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
|
||||
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
|
||||
|
||||
The base version code comes from the package version:
|
||||
|
||||
```text
|
||||
major * 1_000_000 + minor * 1_000 + patch
|
||||
@@ -145,6 +147,37 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr
|
||||
|
||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
||||
|
||||
### F-Droid store metadata
|
||||
|
||||
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/` **at the repo root**. This location provides the best compatibility with the F-Droid release process.
|
||||
|
||||
```text
|
||||
fastlane/metadata/android/
|
||||
├── en-US/ (F-Droid fallback locale, mandatory)
|
||||
│ ├── title.txt (<=50 chars)
|
||||
│ ├── short_description.txt (<=80 chars)
|
||||
│ ├── full_description.txt (<=4000 chars, limited HTML)
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
```
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
|
||||
|
||||
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
|
||||
|
||||
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
|
||||
+11
-8
@@ -104,7 +104,7 @@ 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
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||
- `runtime/directory-sync` owns directory reconciliation. On reconnect it 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.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
@@ -115,12 +115,15 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
||||
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
|
||||
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
|
||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
||||
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
|
||||
host snapshots in least-recently-written order; a single oversized host is omitted rather than
|
||||
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
|
||||
AsyncStorage, and Android reserves 64 MiB for that database.
|
||||
separate product/security decision. Agents, workspaces, and projects have individual rows; the
|
||||
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
|
||||
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
|
||||
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
|
||||
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
|
||||
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
|
||||
IndexedDB. Native builds use expo-sqlite.
|
||||
|
||||
The three directory entity types have independent monotonic sequences and share one daemon
|
||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||
@@ -327,7 +330,7 @@ initializing → idle ⇄ running
|
||||
client-side dedup; the default fetch page is 200 items.
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`. Timeline rows are runtime memory; provider history is the durable transcript authority and resumed agents rebuild from it. That storage path is derived from `cwd`, not from workspace id.
|
||||
|
||||
## Right-sidebar boundary: directory-backed vs workspace-owned
|
||||
|
||||
@@ -402,7 +405,7 @@ Providers that can accept native tool definitions should set `supportsNativePase
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
├── projects/icons/ # Custom project icon images
|
||||
|
||||
+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 ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Base size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. 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:
|
||||
|
||||
|
||||
+12
-2
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||
|
||||
For the desktop explorer toggle, run the app against the root checkout's daemon and use:
|
||||
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
|
||||
|
||||
```bash
|
||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
|
||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
|
||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||
@@ -502,6 +502,16 @@ For tighter loops, you can rebuild a single workspace:
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## Dependency patches
|
||||
|
||||
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
|
||||
+33
-3
@@ -26,8 +26,9 @@ For forge `acme`, the expected end state is:
|
||||
3. **App modules** - a forge splits into a pure logic half and a view half so
|
||||
logic consumers (URL builders, merge-capability, native checks, and the
|
||||
Node-based e2e harness) never pull the client rendering stack:
|
||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
|
||||
optional `facts` (schema, merge-capability, native-check fallbacks). No
|
||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`
|
||||
(source links and pasted issue/change-request reference paths), optional
|
||||
`facts` (schema, merge-capability, native-check fallbacks). No
|
||||
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
||||
`packages/app/src/git/forges/index.ts`.
|
||||
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
||||
@@ -57,8 +58,20 @@ schema-mismatched facts render neutrally instead of failing the whole message
|
||||
parse. This is the version-skew win: an old client can receive facts from a
|
||||
newer forge and still show the PR/MR in a neutral state.
|
||||
|
||||
Check results keep a stable normalized `status` for aggregation and an optional
|
||||
open `traits: string[]` for composable forge-neutral refinements such as
|
||||
`manual`, `action_required`, and `warning`. Consumers ignore unknown traits and
|
||||
fall back to `status`; `rawStatus` preserves provider-native detail. Do not add
|
||||
provider vocabulary to the normalized status union. Producers and the
|
||||
presentation layer share the trait constants in
|
||||
`packages/protocol/src/check-traits.ts` — use those instead of string literals
|
||||
so classification and emission cannot drift apart.
|
||||
|
||||
Shipped GitHub compatibility stays separate:
|
||||
|
||||
- The forge compatibility layer first shipped in `v0.2.0-beta.1`. Its initial
|
||||
six-month cleanup target is `2027-01-17`, after supported client and daemon
|
||||
floors reach stable `v0.2.0`.
|
||||
- `status.github` remains accepted for released peers.
|
||||
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
||||
into `status.github` for older clients.
|
||||
@@ -112,7 +125,7 @@ rendering stack:
|
||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||
|
||||
- `id`
|
||||
- optional `urlGrammar`
|
||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
|
||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||
|
||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||
@@ -181,10 +194,27 @@ are stale, run `npm run build:server`.
|
||||
real shims tagged with `COMPAT(name)`.
|
||||
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
||||
top-level brand is Forgejo or Codeberg.
|
||||
- Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a
|
||||
page size below the requested limit, and omits rerun-attempt numbers. Walk a
|
||||
bounded number of pages using `total_count` and filter locally. Preserve
|
||||
ambiguous same-run tasks: display names are not stable job identities, so
|
||||
name-based deduplication can hide a real failure.
|
||||
- A blocked Gitea Actions task is not necessarily manual: only add the
|
||||
`action_required` trait when the matching run metadata reports
|
||||
`need_approval`.
|
||||
- Brand icons are bundled React components, so they cannot come from protocol
|
||||
manifest data.
|
||||
- Source URL grammars are app-side because blob/tree path syntax is
|
||||
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
||||
rather than constructing a wrong URL.
|
||||
- Pasted issue/change-request URL recognition is also app-side and registry
|
||||
driven. Put the forge's route infixes in `urlGrammar.referencePaths`; the
|
||||
neutral extractor matches the pasted host and full repository path against
|
||||
the current remote before it invokes cwd-routed forge search. Do not add a
|
||||
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||
not protocol.
|
||||
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||
until GitLab reports a terminal status.
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
@@ -43,7 +44,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
|
||||
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
|
||||
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. Sending while the agent waits on a permission denies that request first, so the message reaches the same turn instead of stalling behind it. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
|
||||
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
|
||||
|
||||
+26
-3
@@ -1,7 +1,7 @@
|
||||
# Local plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
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. Plugin code is trusted code;
|
||||
this first slice does not sandbox it.
|
||||
|
||||
@@ -194,5 +194,28 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
||||
attachment-source example.
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||
same semantic builders as the built-in themes, so plugins do not depend on the complete app token
|
||||
contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
|
||||
`packages/app/src/styles/theme.ts` reserves one light and one dark plugin slot. The appearance
|
||||
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
||||
for the runtime-patching rules the appearance settings share.
|
||||
|
||||
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||
that predates it does not, and the plugin fails to start there. Daemons advertise
|
||||
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
|
||||
a host without it contributes no themes.
|
||||
|
||||
The selection persists as `theme: "plugin"` plus a `pluginThemeId` of `<pluginId>/theme/<themeId>`,
|
||||
so equal themes on several hosts coalesce the way sidebar contributions do. Two hosts can answer
|
||||
that id with different palettes, so picking a theme records its host through
|
||||
`rememberPluginContributionHost` and resolution prefers it; a peer connecting or dropping then does
|
||||
not repaint the app. Without a preference the sorted registry snapshot decides, so the result is
|
||||
stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, and `plugin-examples/catppuccin` for a theme.
|
||||
|
||||
+7
-1
@@ -81,10 +81,12 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
`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. The released OpenCode v1 adapter does not implement this operation.
|
||||
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()`.
|
||||
|
||||
`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.
|
||||
|
||||
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
|
||||
|
||||
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
@@ -145,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
|
||||
|
||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||
|
||||
### Usage fetchers are read-only on credentials
|
||||
|
||||
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
@@ -436,6 +436,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- The npm `version` lifecycle regenerates F-Droid changelog files from `CHANGELOG.md` for stable releases only (`npm run fdroid:changelogs`) and stages them, so the release tag carries them. Betas are a no-op. A stable run **aborts the release** if `CHANGELOG.md` has no entry for the version being cut — commit the changelog entry first. See [docs/android.md](android.md) for why these files are generated per ABI.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Side panel
|
||||
|
||||
The companion surface beside the user's work: Changes, Files, the pull request, and
|
||||
anything an agent asks to show alongside its output.
|
||||
|
||||
Everything that opens, reveals, hides, or asks about it goes through
|
||||
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
||||
surface the current layout uses.
|
||||
|
||||
## Three renderings, two facts
|
||||
|
||||
| Layout | Rendering |
|
||||
| ---------------------- | ----------------------------------------------- |
|
||||
| compact | slide-over overlay, owned by the panel store |
|
||||
| non-compact + splits | dedicated right pane, owned by the layout store |
|
||||
| non-compact, no splits | ordinary tabs in the focused pane |
|
||||
|
||||
The third case is the one that bites. A native tablet renders one pane at a time and
|
||||
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
||||
side panel pane there disappears with no way back.
|
||||
|
||||
## Placement
|
||||
|
||||
Where a tab lands is an intent the caller declares, not something the store infers
|
||||
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
||||
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
||||
difference between them only shows up when the tab is already open somewhere:
|
||||
|
||||
| Mode | New tab | Tab already open |
|
||||
| --------- | ----------------- | ---------------------------- |
|
||||
| `pane` | opens in the pane | **moves** to the pane |
|
||||
| `prefer` | opens in the pane | stays where the user left it |
|
||||
| `focused` | focused pane | focuses it in place |
|
||||
| `ambient` | focused pane | focuses it in place |
|
||||
|
||||
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
||||
The user is placing the tab and expects it to arrive.
|
||||
|
||||
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
|
||||
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
|
||||
deliberately moved it to is the failure this mode exists to prevent.
|
||||
|
||||
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
||||
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
||||
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
||||
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
||||
is, because the user is standing in it.
|
||||
|
||||
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
||||
every caller. Do not re-place a tab after opening it.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
||||
it empties or the user dismisses it:
|
||||
|
||||
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
||||
- **Close pane** on any other pane **removes** it.
|
||||
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
||||
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
||||
visible.
|
||||
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
||||
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
||||
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
||||
`setPaneHiddenInLayout`; do not add a second one upstream.
|
||||
|
||||
`closePane` in the layout store owns that distinction. React renders the button and
|
||||
does not decide what it means.
|
||||
|
||||
Dragging a pane's last tab into another pane collapses the source pane — except the
|
||||
side panel, which the user summons and expects to find again, split size and all.
|
||||
|
||||
## What the side panel never does
|
||||
|
||||
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
||||
picks what goes in. Detecting a pull request makes the launcher's card available and
|
||||
opens nothing. A failed workspace setup adds a background tab to the main pane.
|
||||
Running and successful setup never seed tabs.
|
||||
|
||||
## Routing preference
|
||||
|
||||
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
||||
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
||||
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
|
||||
hides the panel without closing the tab. A different visible Side panel tab switches to
|
||||
Changes instead.
|
||||
|
||||
## The name
|
||||
|
||||
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
||||
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
||||
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
||||
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
||||
either would fail every saved blob and wipe the layout it describes.
|
||||
@@ -43,6 +43,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
|
||||
## Known remaining contention (follow-up candidates)
|
||||
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. See [agent-stream-performance.md](agent-stream-performance.md) for that pipeline's own budgets.
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
|
||||
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
||||
|
||||
@@ -5,6 +5,9 @@ Agent chat delivery has two paths:
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
||||
|
||||
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
|
||||
authority and repopulates those rows when an agent resumes.
|
||||
|
||||
The invariants are:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
@@ -12,7 +15,7 @@ The invariants are:
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
||||
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
|
||||
@@ -46,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s
|
||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
||||
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
||||
condition clears.
|
||||
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
|
||||
|
||||
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
||||
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
||||
|
||||
+3
-2
@@ -335,15 +335,16 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
|
||||
|
||||
## Runtime Theme Patching For User Preferences
|
||||
|
||||
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||
|
||||
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
||||
|
||||
Gotchas:
|
||||
|
||||
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
||||
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo is a client for AI coding agents that run on your own machine. Your dev environment, in your pocket.
|
||||
|
||||
Paseo runs a local server called the daemon on your computer. It manages your coding agents and streams their output in real time. This app connects to that daemon, so your code never leaves your machine and there is no cloud service in between.
|
||||
|
||||
<b>Supported agents</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own.
|
||||
|
||||
<b>What you can do</b>
|
||||
|
||||
<ul>
|
||||
<li>Start agents, watch them work, and answer their questions from your phone</li>
|
||||
<li>Run several agents in parallel across projects and git worktrees</li>
|
||||
<li>Review diffs, browse commit history, and edit files</li>
|
||||
<li>Work with pull requests from GitHub, GitLab, Gitea, Forgejo, and Codeberg</li>
|
||||
<li>Open a terminal in any workspace</li>
|
||||
<li>Dictate prompts or talk through a problem in voice mode</li>
|
||||
<li>Schedule agents to run on a recurring basis</li>
|
||||
</ul>
|
||||
|
||||
<b>Privacy</b>
|
||||
|
||||
No telemetry, no tracking, no account required. The app talks only to daemons you point it at. Remote access goes through an end-to-end encrypted relay that cannot read your traffic, or over your own network or VPN if you prefer.
|
||||
|
||||
<b>You need a daemon to connect to</b>
|
||||
|
||||
This app is not useful on its own. Install the Paseo daemon on the computer where your code and agent CLIs live, then pair this app with it from Settings. The desktop app bundles the daemon, or you can run it from npm, Docker, or Nix.
|
||||
|
||||
Setup instructions: https://paseo.sh
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
@@ -0,0 +1 @@
|
||||
Monitor and control your local AI coding agents from anywhere
|
||||
@@ -0,0 +1 @@
|
||||
Paseo
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo は、自分のマシン上で動作する AI コーディングエージェントのクライアントです。あなたの開発環境を、ポケットの中に。
|
||||
|
||||
Paseo はデーモンと呼ばれるローカルサーバーをあなたのコンピューター上で実行します。デーモンがコーディングエージェントを管理し、その出力をリアルタイムに配信します。このアプリはそのデーモンに接続するため、コードがマシンの外に出ることはなく、間にクラウドサービスも入りません。
|
||||
|
||||
<b>対応エージェント</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
プロバイダーの認証情報はご自身のものを使用し、既存のサブスクリプションをそのまま活用できます。Paseo が独自の手数料や推論コストを追加することはありません。
|
||||
|
||||
<b>できること</b>
|
||||
|
||||
<ul>
|
||||
<li>スマートフォンからエージェントを起動し、作業を確認し、質問に回答する</li>
|
||||
<li>プロジェクトや git worktree をまたいで複数のエージェントを並列実行する</li>
|
||||
<li>差分のレビュー、コミット履歴の閲覧、ファイルの編集</li>
|
||||
<li>GitHub、GitLab、Gitea、Forgejo、Codeberg のプルリクエストを扱う</li>
|
||||
<li>任意のワークスペースでターミナルを開く</li>
|
||||
<li>音声でプロンプトを入力したり、音声モードで問題を相談する</li>
|
||||
<li>エージェントを定期実行するようスケジュールする</li>
|
||||
</ul>
|
||||
|
||||
<b>プライバシー</b>
|
||||
|
||||
テレメトリー、トラッキング、アカウント登録は一切ありません。アプリは、あなたが指定したデーモンにのみ通信します。リモートアクセスは通信内容を読み取れないエンドツーエンド暗号化リレーを経由するか、ご自身のネットワークや VPN を利用できます。
|
||||
|
||||
<b>接続先のデーモンが必要です</b>
|
||||
|
||||
このアプリは単体では機能しません。コードとエージェント CLI があるコンピューターに Paseo デーモンをインストールし、設定画面からこのアプリをペアリングしてください。デスクトップアプリにはデーモンが同梱されています。npm、Docker、Nix から実行することもできます。
|
||||
|
||||
セットアップ手順: https://paseo.sh
|
||||
@@ -0,0 +1 @@
|
||||
ローカルのAIコーディングエージェントをどこからでも操作
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo 是运行在你自己机器上的 AI 编程 agents 的客户端。把你的开发环境装进口袋。
|
||||
|
||||
Paseo 会在你的电脑上运行一个名为 daemon 的本地服务,由它管理编程 agents 并实时推送输出。本应用连接到该 daemon,因此你的代码不会离开你的机器,中间也没有任何云服务。
|
||||
|
||||
<b>支持的 agents</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
你使用自己的提供商凭据和现有订阅。Paseo 不加价,也不产生自己的推理费用。
|
||||
|
||||
<b>你可以做什么</b>
|
||||
|
||||
<ul>
|
||||
<li>在手机上启动 agents、查看进展并回答它们的提问</li>
|
||||
<li>跨项目和 git worktree 并行运行多个 agents</li>
|
||||
<li>审查差异、浏览提交历史、编辑文件</li>
|
||||
<li>处理来自 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 的合并请求</li>
|
||||
<li>在任意工作区打开终端</li>
|
||||
<li>用语音输入提示,或在语音模式下讨论问题</li>
|
||||
<li>将 agents 设置为定期运行</li>
|
||||
</ul>
|
||||
|
||||
<b>隐私</b>
|
||||
|
||||
没有遥测、没有追踪、无需账号。应用只与你指定的 daemon 通信。远程访问通过无法读取你流量的端到端加密中继,或者你也可以使用自己的网络或 VPN。
|
||||
|
||||
<b>你需要一个可连接的 daemon</b>
|
||||
|
||||
本应用无法独立使用。请在存放代码和 agent CLI 的电脑上安装 Paseo daemon,然后在设置中将本应用与其配对。桌面应用已内置 daemon,你也可以通过 npm、Docker 或 Nix 运行。
|
||||
|
||||
安装说明:https://paseo.sh
|
||||
@@ -0,0 +1 @@
|
||||
随时随地监控和控制本地的 AI 编程 agents
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
|
||||
sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o=
|
||||
|
||||
Generated
+70
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -15070,6 +15070,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/await-lock": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
|
||||
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
@@ -21336,6 +21342,20 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-sqlite": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
|
||||
"integrity": "sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"await-lock": "^2.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-system-ui": {
|
||||
"version": "6.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
|
||||
@@ -21781,6 +21801,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -36140,7 +36170,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36200,6 +36230,7 @@
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
@@ -36241,6 +36272,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "~22.19.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
@@ -36254,6 +36286,7 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
@@ -36791,6 +36824,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@types/node": {
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-clipboard": {
|
||||
"version": "0.3.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz",
|
||||
@@ -37192,12 +37235,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
||||
"@getpaseo/server": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/server": "0.5.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37444,10 +37487,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
||||
"@getpaseo/relay": "0.5.0-beta.3",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37458,7 +37501,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37701,7 +37744,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38597,7 +38640,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38626,12 +38669,12 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38640,14 +38683,14 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38660,7 +38703,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38878,19 +38921,19 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.5.0-beta.3",
|
||||
"@getpaseo/highlight": "0.5.0-beta.3",
|
||||
"@getpaseo/plugin": "0.5.0-beta.3",
|
||||
"@getpaseo/protocol": "0.5.0-beta.3",
|
||||
"@getpaseo/relay": "0.5.0-beta.3",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/highlight": "0.5.1",
|
||||
"@getpaseo/plugin": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
@@ -39940,7 +39983,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -84,6 +84,8 @@
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
|
||||
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
@@ -91,7 +93,7 @@
|
||||
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version": "npm run version:sync-internal && npm run fdroid:changelogs && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
id: "minimax-code",
|
||||
name: "MiniMax Code",
|
||||
};
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
@@ -22,7 +22,7 @@ interface ProviderCatalogDaemonClient {
|
||||
}
|
||||
|
||||
test.describe("ACP provider catalog", () => {
|
||||
test("adds a catalog provider from settings", async ({ page }) => {
|
||||
test("adds MiniMax Code from settings", async ({ page }) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "provider-catalog-e2e",
|
||||
});
|
||||
|
||||
@@ -726,7 +726,7 @@ async function recordDelayedRunningTransition(
|
||||
gate.holdNextAgentStreamEvent("turn_started");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "running");
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await installActivityContinuityOracle(page, prompt);
|
||||
await submitMessage(page, prompt);
|
||||
@@ -779,13 +779,13 @@ async function recordDelayedRunningTransition(
|
||||
const checkpoints = await readActivityContinuityOracle(page);
|
||||
expectActivityContinuity(checkpoints);
|
||||
expect(elapsedBeforeAuthoritative).toBe(0);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
return { frames, checkpoints };
|
||||
} finally {
|
||||
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
|
||||
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
|
||||
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,6 +814,8 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Delay synthetic user message by 300ms.";
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.setAgentStreamEventSuppressed("timeline", true);
|
||||
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await recordTurnFrames(page, prompt);
|
||||
@@ -821,7 +823,10 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
|
||||
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
|
||||
await expect(submittedRow).toBeVisible();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response");
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.truncateHeldTimelineAfterLast("user_message");
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
|
||||
await recordPaintsFor(page, 80);
|
||||
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
||||
} finally {
|
||||
|
||||
@@ -3,10 +3,13 @@ import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import {
|
||||
cancelAgent,
|
||||
fillComposerDraft,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
|
||||
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function completeSubmittedTurn(
|
||||
@@ -42,9 +45,13 @@ async function expectTurnCompletesNormally(
|
||||
}
|
||||
|
||||
test.describe("Agent message rewind", () => {
|
||||
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
|
||||
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const prompt = "Restore this submitted prompt after rewind.";
|
||||
const retainedPrompt = "Keep this completed turn after rewind.";
|
||||
const rewoundPrompt = "Remove this completed turn after rewind.";
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "message-rewind-roundtrip-",
|
||||
title: "Message rewind roundtrip",
|
||||
@@ -52,14 +59,30 @@ test.describe("Agent message rewind", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const userMessage = await completeSubmittedTurn(page, agent, prompt);
|
||||
await completeSubmittedTurn(page, agent, retainedPrompt);
|
||||
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
rewindCompletion.clearTimelineStreamCount();
|
||||
|
||||
await rewindConversation(page, userMessage, prompt);
|
||||
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
|
||||
await rewindCompletion.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
|
||||
expect(rewindCompletion.timelineStreamCount()).toBe(0);
|
||||
rewindCompletion.release();
|
||||
await rewind;
|
||||
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
||||
await expectComposerDraft(page, prompt);
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -315,10 +315,11 @@ async function configureInterruptInSettings(page: Page): Promise<void> {
|
||||
|
||||
async function selectSendBehaviorInSettings(
|
||||
page: Page,
|
||||
buttonName: string,
|
||||
behaviorLabel: string,
|
||||
stored: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
||||
await page.getByRole("button", { name: /^Default send: / }).click();
|
||||
await page.getByRole("menuitem", { name: behaviorLabel, exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { startRunningMockAgent } from "../support/helpers/composer";
|
||||
import {
|
||||
sampleStreamFrames,
|
||||
summarizeStreamSmoothness,
|
||||
} from "../support/helpers/stream-smoothness";
|
||||
|
||||
/**
|
||||
* Streaming smoothness, measured on the path the user actually sees.
|
||||
*
|
||||
* The mock provider's "bursty-stream" model emits tokens in uneven runs separated
|
||||
* by idle gaps, which is how real models arrive. Sampling and the statistics live
|
||||
* in ../support/helpers/stream-smoothness.ts, which also documents what the two
|
||||
* numbers mean.
|
||||
*
|
||||
* Budgets are deliberately loose. This spec exists to catch a regression in the
|
||||
* shape of the stream, not to pin exact numbers on shared CI hardware. For
|
||||
* reference, on a local dev daemon the paced reveal measures a CV around 1.3–1.5
|
||||
* against 2.7 when painting deltas as they arrive.
|
||||
*/
|
||||
|
||||
const SAMPLE_WINDOW_MS = 6_000;
|
||||
const CHARS_PER_FRAME_CV_BUDGET = 2;
|
||||
const UPDATE_GAP_P95_BUDGET_MS = 250;
|
||||
const REACT_COMMIT_P95_BUDGET_MS = 12;
|
||||
const REACT_COMMIT_COUNT_BUDGET = 600;
|
||||
const RUN_AGENT_STREAM_PERF = process.env.PASEO_AGENT_STREAM_PERF_E2E === "1";
|
||||
const agentStreamPerfDescribe = RUN_AGENT_STREAM_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
interface ReactCommit {
|
||||
id: string;
|
||||
phase: "mount" | "update" | "nested-update";
|
||||
actualDuration: number;
|
||||
baseDuration: number;
|
||||
startTime: number;
|
||||
commitTime: number;
|
||||
}
|
||||
|
||||
agentStreamPerfDescribe("Agent stream smoothness", () => {
|
||||
test("reveals bursty model output at a steady rate", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
|
||||
});
|
||||
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-smoothness-",
|
||||
model: "bursty-stream",
|
||||
prompt: "Stream bursty output for smoothness measurement.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await page.evaluate(() => {
|
||||
const reset = Reflect.get(globalThis, "__PASEO_RESET_RENDER_PROFILE__");
|
||||
if (typeof reset !== "function") {
|
||||
throw new Error("Render profiler did not initialize");
|
||||
}
|
||||
reset();
|
||||
});
|
||||
|
||||
const samples = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
|
||||
const report = summarizeStreamSmoothness(samples, SAMPLE_WINDOW_MS);
|
||||
const reactCommits = await readStreamReactCommits(page);
|
||||
const reactReport = summarizeReactCommits(reactCommits);
|
||||
|
||||
await testInfo.attach("agent-stream-smoothness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("agent-stream-react-profile", {
|
||||
body: JSON.stringify(reactReport, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Agent stream React: ${JSON.stringify(reactReport)}`);
|
||||
console.log(`[perf] Agent stream smoothness: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(
|
||||
report.growthFrames,
|
||||
"the stream must actually produce text during the sample window",
|
||||
).toBeGreaterThan(20);
|
||||
expect(
|
||||
report.charsPerFrameCv,
|
||||
`characters painted per frame should be steady (cv < ${CHARS_PER_FRAME_CV_BUDGET})`,
|
||||
).toBeLessThan(CHARS_PER_FRAME_CV_BUDGET);
|
||||
expect(
|
||||
report.updateGapP95Ms,
|
||||
`the text should not stall (p95 gap < ${UPDATE_GAP_P95_BUDGET_MS}ms)`,
|
||||
).toBeLessThan(UPDATE_GAP_P95_BUDGET_MS);
|
||||
expect(reactReport.count, "streaming should produce measured React commits").toBeGreaterThan(
|
||||
20,
|
||||
);
|
||||
expect(
|
||||
reactReport.count,
|
||||
`the reveal should cap commits below ${REACT_COMMIT_COUNT_BUDGET} per sample window`,
|
||||
).toBeLessThan(REACT_COMMIT_COUNT_BUDGET);
|
||||
expect(
|
||||
reactReport.actualDurationP95Ms,
|
||||
`React commits should stay below ${REACT_COMMIT_P95_BUDGET_MS}ms at p95`,
|
||||
).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function readStreamReactCommits(
|
||||
page: Parameters<typeof sampleStreamFrames>[0],
|
||||
): Promise<ReactCommit[]> {
|
||||
return await page.evaluate(() => {
|
||||
const samples = Reflect.get(globalThis, "__PASEO_RENDER_PROFILE__");
|
||||
if (!Array.isArray(samples)) {
|
||||
return [];
|
||||
}
|
||||
return samples.filter(
|
||||
(sample): sample is ReactCommit =>
|
||||
typeof sample === "object" &&
|
||||
sample !== null &&
|
||||
typeof Reflect.get(sample, "id") === "string" &&
|
||||
Reflect.get(sample, "id").startsWith("AgentStreamSection:"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeReactCommits(commits: readonly ReactCommit[]) {
|
||||
const durations = commits
|
||||
.map((commit) => commit.actualDuration)
|
||||
.sort((left, right) => left - right);
|
||||
const total = durations.reduce((sum, duration) => sum + duration, 0);
|
||||
return {
|
||||
count: commits.length,
|
||||
actualDurationTotalMs: round2(total),
|
||||
actualDurationMeanMs: round2(commits.length > 0 ? total / commits.length : 0),
|
||||
actualDurationP50Ms: percentile(durations, 50),
|
||||
actualDurationP95Ms: percentile(durations, 95),
|
||||
actualDurationMaxMs: durations.at(-1) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function percentile(sorted: readonly number[], percentileValue: number): number {
|
||||
if (sorted.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return sorted[
|
||||
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * (percentileValue / 100)))
|
||||
];
|
||||
}
|
||||
@@ -91,14 +91,16 @@ test.describe("Agent timeline pagination", () => {
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptCentered(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.initialTailOldestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
await history.expectRequestedPages(2);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -45,7 +45,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the base font size to settings text", async ({ page }) => {
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
|
||||
});
|
||||
@@ -56,11 +56,14 @@ test("applies the base font size to settings text", async ({ page }) => {
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Base font size");
|
||||
await expect(fontSizeInput).toHaveValue("21");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
const interfaceSizeInput = page.getByLabel("Interface font size");
|
||||
const contentSizeInput = page.getByLabel("Content font size");
|
||||
await expect(interfaceSizeInput).toHaveValue("21");
|
||||
await expect(contentSizeInput).toHaveValue("21");
|
||||
await interfaceSizeInput.fill("12");
|
||||
await interfaceSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(interfaceSizeInput).toHaveValue("12");
|
||||
await expect(contentSizeInput).toHaveValue("21");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "10px");
|
||||
});
|
||||
|
||||
@@ -258,6 +258,61 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||
|
||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
|
||||
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -648,7 +703,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
|
||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
@@ -658,6 +713,41 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
||||
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
||||
await Promise.all([
|
||||
page.getByTestId("changes-header").boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
||||
throw new Error("Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(changesToolbarBox.height).toBe(36);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(optionsBox.width).toBe(24);
|
||||
expect(optionsBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(optionsGlyphBox.width).toBe(14);
|
||||
expect(optionsGlyphBox.height).toBe(14);
|
||||
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
||||
const diffOptionsRestBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await diffOptions.hover();
|
||||
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
||||
await page.getByTestId("changes-toggle-tree").hover();
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
||||
|
||||
@@ -675,6 +765,12 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
const tree = changesTree(page);
|
||||
const content = changesContent(page);
|
||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||
@@ -735,7 +831,49 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
||||
await expectFlatFileList(page);
|
||||
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||
actions.boundingBox(),
|
||||
options.boundingBox(),
|
||||
options.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!actionsBox || !optionsBox || !glyphBox) {
|
||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(actionsBox.width).toBe(48);
|
||||
expect(actionsBox.height).toBe(28);
|
||||
expect(optionsBox.width).toBe(32);
|
||||
expect(optionsBox.height).toBe(32);
|
||||
expect(glyphBox.width).toBe(18);
|
||||
expect(glyphBox.height).toBe(18);
|
||||
|
||||
await expect(actions).not.toContainText("Commit");
|
||||
await expect(actions.locator("svg")).toHaveCount(2);
|
||||
await actions.click();
|
||||
await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await options.click();
|
||||
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||
await expect(wrapLines).toBeVisible();
|
||||
await wrapLines.click();
|
||||
await options.click();
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
@@ -869,12 +1007,30 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect
|
||||
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||
await startReviewOnFirstChangedLine(page, "right");
|
||||
const [editor, body] = await Promise.all([
|
||||
page.getByTestId("inline-review-editor").boundingBox(),
|
||||
@@ -967,7 +1123,7 @@ test("canvas diff copies a dragged character selection without opening a review"
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
@@ -977,7 +1133,7 @@ test("the first click after a canvas selection only dismisses the selection", as
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||
@@ -1004,6 +1160,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||
const before = await readSelectionPaintSamples(page, "right");
|
||||
await dragAddedTextRange(page, {
|
||||
lines,
|
||||
@@ -1027,6 +1184,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await dragAddedTextRange(page, {
|
||||
lines: [content],
|
||||
wrapped: true,
|
||||
@@ -1151,6 +1309,31 @@ async function configureDiffPresentation(
|
||||
);
|
||||
}
|
||||
|
||||
async function setOpenChangesPresentation(
|
||||
page: Page,
|
||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||
): Promise<void> {
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
|
||||
await options.click();
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
||||
if (currentLayout !== requestedPresentation.layout) {
|
||||
await layoutItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await options.click();
|
||||
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
||||
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
||||
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||
await wrapItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
}
|
||||
|
||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fontSet = document.fonts;
|
||||
@@ -1265,6 +1448,28 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-committed-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
|
||||
await writeFile(path.join(repo.path, "committed-only.ts"), "export const committed = true;\n");
|
||||
execFileSync("git", ["add", "committed-only.ts"], { cwd: repo.path });
|
||||
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd: repo.path });
|
||||
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create committed workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||
files: [{ path: "src/selection.ts", content: "" }],
|
||||
@@ -1290,6 +1495,13 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
@@ -1353,7 +1565,8 @@ async function startReviewOnFirstChangedLine(
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
||||
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||
await page.getByRole("button", { name: "Add review comment" }).click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||
|
||||
function mainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page) {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -119,6 +119,7 @@ test.describe("desktop chat outline", () => {
|
||||
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
||||
await focusChatOutlinePrompt(page, 1);
|
||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await pressEnterOnFocusedPrompt(page);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
|
||||
|
||||
@@ -51,6 +51,19 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
const [commitToolbarBox, layoutToggleBox, layoutToggleGlyphBox] = await Promise.all([
|
||||
panel.getByTestId("commit-diff-header").boundingBox(),
|
||||
layoutToggle.boundingBox(),
|
||||
layoutToggle.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!commitToolbarBox || !layoutToggleBox || !layoutToggleGlyphBox) {
|
||||
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||
}
|
||||
expect(commitToolbarBox.height).toBe(36);
|
||||
expect(layoutToggleBox.width).toBe(24);
|
||||
expect(layoutToggleBox.height).toBe(24);
|
||||
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expectCommitDiffHeaderGeometry(panel);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function visibleMainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Composer diff stat",
|
||||
repo: { withRemote: true },
|
||||
});
|
||||
try {
|
||||
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "README.md"),
|
||||
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
|
||||
);
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const workspaces = await workspace.client.fetchWorkspaces();
|
||||
return (
|
||||
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
|
||||
);
|
||||
})
|
||||
.toEqual({ additions: 2, deletions: 0 });
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const pill = page.getByTestId("composer-diff-stat-pill");
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("+2");
|
||||
await expect(pill).toContainText("-0");
|
||||
await pill.click();
|
||||
|
||||
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await test.step("switching from another Side panel view reveals Changes", async () => {
|
||||
await openFilesPanel(page);
|
||||
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await pill.click();
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
|
||||
await pill.click();
|
||||
await expect(sidePanel).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("clicking again restores Changes", async () => {
|
||||
await pill.click();
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
|
||||
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
|
||||
const mainPane = visibleMainPane(page);
|
||||
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectSingleCanvasSurface(page);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleNewTabPanel(page: Page): Locator {
|
||||
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
|
||||
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||
}
|
||||
|
||||
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+E`);
|
||||
const launcher = page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-new-tab-panel");
|
||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||
return launcher;
|
||||
}
|
||||
|
||||
test.describe("New tab keyboard launcher", () => {
|
||||
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const launcher = await openSidePanelWithKeyboard(page);
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-tab-files"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
|
||||
const launcher = visibleNewTabPanel(page);
|
||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||
await expectLauncherSelection(launcher, "Agent");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Terminal");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,13 @@
|
||||
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
||||
// MAIN pane in the background — the explorer pane is a background surface and must
|
||||
// never swallow entity tabs or lose the user's focus, and
|
||||
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
|
||||
// interactive in the [agent | empty main | explorer] state (the reporter hit an
|
||||
// app-wide lockup here with no way to close the empty pane).
|
||||
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
|
||||
// interactive in the [agent | New | explorer] state.
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
@@ -37,9 +36,8 @@ function mainTabRow(page: Page): Locator {
|
||||
}
|
||||
|
||||
async function focusExplorerPane(page: Page): Promise<void> {
|
||||
await ensureExplorerPane(page);
|
||||
await openChangesPanel(page);
|
||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
||||
await changesTab.click({ position: { x: 12, y: 13 } });
|
||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
@@ -54,15 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = page
|
||||
function newTabPaneChild(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasText: "No tabs in this pane." })
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = newTabPaneChild(page);
|
||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||
const box = await paneChild.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) throw new Error("Empty pane has no bounding box");
|
||||
if (!box) throw new Error("New tab pane has no bounding box");
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -94,7 +96,7 @@ async function dragChipTo(
|
||||
}
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const consoleErrors: string[] = [];
|
||||
@@ -168,9 +170,9 @@ test.describe("explorer pane tab placement", () => {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
||||
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
@@ -183,19 +185,12 @@ test.describe("explorer pane tab placement", () => {
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the empty pane (focuses it) must not lock anything.
|
||||
const emptyPane = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasText: "No tabs in this pane." })
|
||||
.first();
|
||||
await emptyPane.click({ timeout: 5_000 });
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// The empty pane's + menu must open and dismiss cleanly.
|
||||
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
||||
await emptyPaneTrigger.click({ timeout: 5_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// A second drag must work: center-drop the agent tab back into the empty pane.
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
@@ -206,8 +201,10 @@ test.describe("explorer pane tab placement", () => {
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
||||
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } });
|
||||
|
||||
@@ -24,11 +24,9 @@ function dockedSidebar(page: Page): Locator {
|
||||
return visible(page, "explorer-header");
|
||||
}
|
||||
|
||||
/** The explorer pane is the tab row carrying the Changes tab. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
has: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
/** The desktop companion surface is a hideable side panel. */
|
||||
function sidePanel(page: Page): Locator {
|
||||
return visible(page, "workspace-side-panel");
|
||||
}
|
||||
|
||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||
@@ -72,11 +70,12 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -108,20 +107,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("the shortcut collapses both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(agentList).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the shortcut restores both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-source-lockfile-",
|
||||
title: "Large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
||||
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openWorkspaceFile(page, "package-lock.json");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(editor(page)).toContainText('"package-0"');
|
||||
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-source-plain-",
|
||||
title: "Plain large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "plain.txt"),
|
||||
"plain source\n".repeat(1_050_000),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openWorkspaceFile(page, "plain.txt");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(editor(page)).toContainText("plain source");
|
||||
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-source-unsupported-",
|
||||
title: "Unsupported large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "too-large.txt"),
|
||||
Buffer.alloc(51 * 1024 * 1024),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openWorkspaceFile(page, "too-large.txt");
|
||||
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
||||
"This file is too large to display",
|
||||
);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
||||
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink({
|
||||
@@ -117,7 +198,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await sourceEditor.press("ControlOrMeta+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -241,13 +322,13 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await content.press("ControlOrMeta+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
await content.press("ControlOrMeta+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
@@ -413,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-transition-",
|
||||
repo: {
|
||||
files: [
|
||||
{ path: "src/a.ts", content: "export const sourceA = true;\n" },
|
||||
{ path: "src/b.ts", content: "export const sourceB = true;\n" },
|
||||
{ path: "docs/b.md", content: "# Markdown B\n" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => workspace?.cleanup());
|
||||
|
||||
async function openSourceA(page: import("@playwright/test").Page) {
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "src");
|
||||
await openFileFromExplorer(page, "a.ts");
|
||||
await expect(page.getByText("export const sourceA = true;")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function assertHeldFileTransition(
|
||||
page: import("@playwright/test").Page,
|
||||
targetPath: string,
|
||||
finalText: string,
|
||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||
) {
|
||||
await page.evaluate(() => {
|
||||
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
|
||||
(
|
||||
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
|
||||
).__fileTransitionOriginalChip = originalChip;
|
||||
});
|
||||
await page.evaluate((tabTestId) => {
|
||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||
const sample = () => {
|
||||
const next = {
|
||||
chip: Boolean(document.querySelector(`[data-testid="${tabTestId}"]`)),
|
||||
loading: Boolean(document.querySelector('[data-testid="file-preview-loading"]')),
|
||||
unavailable: Boolean(document.querySelector('[data-testid="file-preview-unsupported"]')),
|
||||
};
|
||||
if (JSON.stringify(timeline.at(-1)) !== JSON.stringify(next)) timeline.push(next);
|
||||
};
|
||||
sample();
|
||||
const observer = new MutationObserver(sample);
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
(
|
||||
window as typeof window & { __fileTransitionTimeline?: typeof timeline }
|
||||
).__fileTransitionTimeline = timeline;
|
||||
(
|
||||
window as typeof window & { __fileTransitionObserver?: MutationObserver }
|
||||
).__fileTransitionObserver = observer;
|
||||
}, `workspace-tab-file_${targetPath}`);
|
||||
await openFileFromExplorer(page, targetPath.split("/").pop() as string);
|
||||
await gate.waitUntilHeld();
|
||||
|
||||
const targetChip = page.getByTestId(`workspace-tab-file_${targetPath}`).first();
|
||||
await expect(targetChip).toBeVisible();
|
||||
await expect(targetChip).toContainText(targetPath.split("/").pop() as string);
|
||||
await expect(page.getByTestId("file-preview-loading")).toBeVisible();
|
||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||
|
||||
const chipIdentity = await page.evaluate((tabTestId) => {
|
||||
const windowWithOriginal = window as typeof window & {
|
||||
__fileTransitionOriginalChip?: Element | null;
|
||||
};
|
||||
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
|
||||
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||
return {
|
||||
originalStillConnected: originalChip?.isConnected ?? false,
|
||||
sameNode: originalChip === currentChip,
|
||||
};
|
||||
}, `workspace-tab-file_${targetPath}`);
|
||||
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
|
||||
|
||||
const heldTimeline = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__fileTransitionTimeline?: Array<{
|
||||
chip: boolean;
|
||||
loading: boolean;
|
||||
unavailable: boolean;
|
||||
}>;
|
||||
}
|
||||
).__fileTransitionTimeline ?? [],
|
||||
);
|
||||
expect(heldTimeline).toContainEqual(expect.objectContaining({ chip: true, loading: true }));
|
||||
expect(heldTimeline).not.toContainEqual(expect.objectContaining({ unavailable: true }));
|
||||
const descriptorIndex = heldTimeline.findIndex((sample) => sample.chip);
|
||||
expect(heldTimeline.slice(descriptorIndex)).not.toContainEqual(
|
||||
expect.objectContaining({ chip: false }),
|
||||
);
|
||||
|
||||
gate.release();
|
||||
await expect(page.getByText(finalText)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("file-preview-loading")).toHaveCount(0);
|
||||
const timeline = await page.evaluate(() => {
|
||||
const windowWithTimeline = window as typeof window & {
|
||||
__fileTransitionTimeline?: Array<{ chip: boolean; loading: boolean; unavailable: boolean }>;
|
||||
__fileTransitionObserver?: MutationObserver;
|
||||
};
|
||||
windowWithTimeline.__fileTransitionObserver?.disconnect();
|
||||
return windowWithTimeline.__fileTransitionTimeline ?? [];
|
||||
});
|
||||
expect(
|
||||
timeline.filter((sample, index) => sample.loading && !timeline[index - 1]?.loading),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
test.describe("File target transitions", () => {
|
||||
test("keeps the Markdown descriptor and loading state stable through a real delayed read", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await delayFileReadResponse(page, "docs/b.md");
|
||||
await page.reload();
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openSourceA(page);
|
||||
await expandFolder(page, "docs");
|
||||
await assertHeldFileTransition(page, "docs/b.md", "Markdown B", gate);
|
||||
});
|
||||
|
||||
test("keeps the source descriptor and loading state stable through a real delayed read", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await delayFileReadResponse(page, "src/b.ts");
|
||||
await page.reload();
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openSourceA(page);
|
||||
await assertHeldFileTransition(page, "src/b.ts", "export const sourceB = true;", gate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
||||
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
||||
// was permanent on desktop.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
|
||||
return locator.evaluate((element) => {
|
||||
let current: Element | null = element;
|
||||
while (current) {
|
||||
const color = getComputedStyle(current).backgroundColor;
|
||||
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return "transparent";
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-tree-rail-toggle-",
|
||||
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File panel tree rail", () => {
|
||||
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
const content = rail.getByTestId("files-tree-rail-content");
|
||||
const tree = rail.getByTestId("files-tree-rail-tree");
|
||||
await expect(content).toHaveText("Choose a file");
|
||||
await expect(tree).toContainText("docs");
|
||||
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
|
||||
await effectiveBackground(content),
|
||||
);
|
||||
});
|
||||
|
||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
|
||||
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
||||
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
||||
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
||||
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
||||
const hiddenFiles = page
|
||||
.getByRole("button", { name: "Hide hidden files" })
|
||||
.filter({ visible: true });
|
||||
const shoot = async (name: string) => {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
};
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
const [
|
||||
editorToolbarBox,
|
||||
filesToolbarBox,
|
||||
treeToggleBox,
|
||||
refreshBox,
|
||||
treeGlyphBox,
|
||||
refreshGlyphBox,
|
||||
] = await Promise.all([
|
||||
editorToolbar.boundingBox(),
|
||||
filesToolbar.boundingBox(),
|
||||
toggle.boundingBox(),
|
||||
refresh.boundingBox(),
|
||||
toggle.locator("svg").boundingBox(),
|
||||
refresh.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!editorToolbarBox ||
|
||||
!filesToolbarBox ||
|
||||
!treeToggleBox ||
|
||||
!refreshBox ||
|
||||
!treeGlyphBox ||
|
||||
!refreshGlyphBox
|
||||
) {
|
||||
throw new Error("Pane-content toolbar geometry could not be measured");
|
||||
}
|
||||
expect(editorToolbarBox.height).toBe(36);
|
||||
expect(filesToolbarBox.height).toBe(36);
|
||||
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
||||
filesToolbarBox.y + filesToolbarBox.height,
|
||||
);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(refreshBox.width).toBe(24);
|
||||
expect(refreshBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(refreshGlyphBox.width).toBe(14);
|
||||
expect(refreshGlyphBox.height).toBe(14);
|
||||
|
||||
const restBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
const treeToggleSelectedBackground = await toggle.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await refresh.hover();
|
||||
const refreshHoverBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(refreshHoverBackground).not.toBe(restBackground);
|
||||
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
||||
await hiddenFiles.click();
|
||||
const hiddenFilesSelected = page
|
||||
.getByRole("button", { name: "Show hidden files" })
|
||||
.filter({ visible: true });
|
||||
expect(
|
||||
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(refreshHoverBackground);
|
||||
await shoot("tree-open");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(rail).toHaveCount(0);
|
||||
await shoot("tree-closed");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(rail).toBeVisible();
|
||||
await shoot("tree-reopened");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
countTabsOfKind,
|
||||
@@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||
return tabs.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
);
|
||||
}
|
||||
|
||||
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
|
||||
return JSON.stringify(snapshot.map(({ id }) => id));
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||
const created = await workspace.client.createWorkspace({
|
||||
@@ -66,14 +76,12 @@ test.afterAll(async () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab creation", () => {
|
||||
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
|
||||
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
|
||||
if (!secondWorkspaceId) {
|
||||
throw new Error("Secondary workspace was not created");
|
||||
}
|
||||
|
||||
const serverId = getServerId();
|
||||
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
|
||||
|
||||
const switchWorkspaceRow = async (workspaceId: string) => {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -92,11 +100,12 @@ test.describe("Tab creation", () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await switchWorkspaceRow(sequence[i % sequence.length]);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
`New tab did not open after switch ${i}`,
|
||||
).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,59 +113,76 @@ test.describe("Tab creation", () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const workspaceUrl = page.url();
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
|
||||
await page.goto(workspaceUrl);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||
});
|
||||
|
||||
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
|
||||
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
||||
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||
});
|
||||
|
||||
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
const countBefore = await newTabs.count();
|
||||
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countBefore + 1);
|
||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countAfterFirst + 1);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
|
||||
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
const agent = page.getByRole("menuitem", { name: /^Agent/ });
|
||||
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
|
||||
const changes = page.getByRole("menuitem", { name: /Changes/ });
|
||||
const files = page.getByRole("menuitem", { name: /Files/ });
|
||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||
const changes = panel.getByRole("button", { name: /Changes/ });
|
||||
const files = panel.getByRole("button", { name: /Files/ });
|
||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
@@ -164,12 +190,10 @@ test.describe("Tab creation", () => {
|
||||
await expect(terminal).toBeFocused();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(agent).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
|
||||
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||
await panel.click({ position: { x: 20, y: 20 } });
|
||||
await expect(agent).toBeFocused();
|
||||
});
|
||||
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
@@ -205,8 +229,12 @@ test.describe("Tab creation", () => {
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
||||
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu")
|
||||
.filter({ visible: true })
|
||||
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
|
||||
.click();
|
||||
|
||||
await expectTerminalOutputContains(page, "prompt-args: 0");
|
||||
} finally {
|
||||
@@ -214,6 +242,20 @@ test.describe("Tab creation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal profiles are grouped with a settings action", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||
|
||||
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
|
||||
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
|
||||
|
||||
await editProfiles.click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
@@ -226,15 +268,13 @@ test.describe("Tab creation", () => {
|
||||
|
||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||
const newTabButtonInScroll = await scrollArea
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.count();
|
||||
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||
const scrollShades = await tabRow
|
||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||
.count();
|
||||
|
||||
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
||||
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
|
||||
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,28 +373,34 @@ test.describe("Terminal title propagation", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
|
||||
await isolatedWorkspace.navigateTo();
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
||||
// Sample the single New → Agent replacement, not the separate action that
|
||||
// creates the New tab in the first place.
|
||||
const snapshots = await sampleTabsDuringTransition(page, async () => {
|
||||
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
|
||||
});
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
||||
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
||||
// so we allow +2 tolerance for accumulated state and React render batching.
|
||||
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
|
||||
const counts = snapshots.map((snapshot) => snapshot.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
||||
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
||||
2,
|
||||
);
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Existing startup agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "ready",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
|
||||
function recordVisibleNewTab() {
|
||||
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
|
||||
if (panel?.getClientRects().length) {
|
||||
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
|
||||
}
|
||||
}
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
recordVisibleNewTab();
|
||||
});
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { cleanupRewindFlow, type AgentHandle, launchAgent } from "../support/helpers/rewind-flow";
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
providerConfig: { model: "haiku", modeId: "plan" },
|
||||
planPrompt:
|
||||
"Do not ask questions. Produce a concise plan to append the line 'Hello' to README.md, then present it for approval. Do not implement it.",
|
||||
planText: "README.md",
|
||||
steerPrompt: "Do not implement anything. Reply exactly CLAUDE_STEER_RECEIVED and stop.",
|
||||
reply: "CLAUDE_STEER_RECEIVED",
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
planPrompt:
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
|
||||
planText: "Inspect permission steering",
|
||||
steerPrompt: "Do not produce another plan. Reply exactly CODEX_STEER_RECEIVED and stop.",
|
||||
reply: "CODEX_STEER_RECEIVED",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("composer steer supersedes plan approval", () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await submitMessage(page, scenario.planPrompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
|
||||
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||
await expect(pendingPlan).toContainText(scenario.planText);
|
||||
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||
await page.screenshot({ path: pendingScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||
path: pendingScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await submitMessage(page, scenario.steerPrompt);
|
||||
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||
).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
|
||||
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||
await page.screenshot({ path: rejectedScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||
path: rejectedScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
// Catppuccin Mocha: base, text, surface0, surface1, mauve, subtext0, overlay0.
|
||||
const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
plugin.addTheme({
|
||||
id: "mocha",
|
||||
name: "Catppuccin Mocha",
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
plugin.addTheme({
|
||||
id: "latte",
|
||||
name: "Catppuccin Latte",
|
||||
appearance: "light",
|
||||
colors: {
|
||||
background: "#eff1f5",
|
||||
foreground: "#4c4f69",
|
||||
raised: "#e6e9ef",
|
||||
control: "#dce0e8",
|
||||
border: "#ccd0da",
|
||||
accent: "#8839ef",
|
||||
mutedForeground: "#6c6f85",
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
// settingsStyles.sectionHeaderTitle paints from foregroundMuted, so the section heading proves the
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
|
||||
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker.png"),
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await test.step("a contributed light theme uses the light palette", async () => {
|
||||
await page.getByText("Catppuccin Latte", { exact: true }).click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-applied.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await test.step("the selection survives a reload", async () => {
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("removing the plugin falls back to the default theme", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-fallback.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -102,11 +102,11 @@ test.describe("PR pane", () => {
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct passed/failed/pending counts", 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 expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
|
||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
@@ -120,7 +120,7 @@ test.describe("PR pane", () => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
|
||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
beginReplicaCacheObservation,
|
||||
finishReplicaCacheObservation,
|
||||
installReplicaCacheMeasurement,
|
||||
measureReplicaCacheRestore,
|
||||
type ReplicaCacheMeasurementReport,
|
||||
} from "../support/helpers/replica-cache-measurement";
|
||||
|
||||
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
|
||||
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
|
||||
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
|
||||
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
|
||||
const HYDRATED_SELECTOR =
|
||||
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
|
||||
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
|
||||
|
||||
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
|
||||
|
||||
measurementDescribe("Replica cache live measurement", () => {
|
||||
test("observes persistence and cached restore without mutating the daemon", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(OBSERVATION_MS + 90_000);
|
||||
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
|
||||
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
|
||||
|
||||
await installReplicaCacheMeasurement(page);
|
||||
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
|
||||
const observation = await beginReplicaCacheObservation(page);
|
||||
await page.waitForTimeout(OBSERVATION_MS);
|
||||
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
|
||||
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
|
||||
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
|
||||
|
||||
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
|
||||
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await testInfo.attach("replica-cache-measurement", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
||||
const MAX_STREAM_WRITES = 12;
|
||||
const MAX_SERIALIZED_CHARS = 190_000;
|
||||
const MAX_SERIALIZED_CHARS = 45_000;
|
||||
|
||||
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
||||
replicaCacheAgent: async ({ page: _page }, provide) => {
|
||||
|
||||
@@ -70,9 +70,28 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
|
||||
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
||||
const defaultSendTrigger = page.getByRole("button", {
|
||||
name: "Default send: Steer",
|
||||
exact: true,
|
||||
});
|
||||
await expect(defaultSendTrigger).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Queue", exact: true })).toHaveCount(0);
|
||||
|
||||
await defaultSendTrigger.click();
|
||||
await expect(page.getByRole("menuitem", { name: "Steer", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Queue", exact: true }).click();
|
||||
await expectSendBehavior(page, "queue");
|
||||
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
||||
const queuedDefaultSendTrigger = page.getByRole("button", {
|
||||
name: "Default send: Queue",
|
||||
exact: true,
|
||||
});
|
||||
await expect(queuedDefaultSendTrigger).toBeVisible();
|
||||
|
||||
await queuedDefaultSendTrigger.click();
|
||||
await page.getByRole("menuitem", { name: "Interrupt", exact: true }).click();
|
||||
await expectSendBehavior(page, "interrupt");
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page): Locator {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
function mainPane(page: Page): Locator {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function newTabPanel(scope: Locator): Locator {
|
||||
return scope.getByTestId("workspace-new-tab-panel");
|
||||
}
|
||||
|
||||
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
|
||||
return launcher
|
||||
.getByRole("button")
|
||||
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
|
||||
}
|
||||
|
||||
function sidePanelToggle(page: Page): Locator {
|
||||
return page.getByTestId("workspace-explorer-toggle").first();
|
||||
}
|
||||
|
||||
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
||||
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
||||
const paneBox = await mainPane(page).boundingBox();
|
||||
const chipBox = await chip.boundingBox();
|
||||
expect(paneBox).not.toBeNull();
|
||||
expect(chipBox).not.toBeNull();
|
||||
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
||||
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
||||
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Side panel", () => {
|
||||
test("reveals empty, launches a view, and hides back out of the way", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
|
||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||
const launcher = newTabPanel(sidePanel(page));
|
||||
await expect(launcher).toBeVisible();
|
||||
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
|
||||
"Changes",
|
||||
"Files",
|
||||
"Terminal",
|
||||
"Agent",
|
||||
]);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
||||
});
|
||||
|
||||
await test.step("Changes opens in the Side panel that offered it", async () => {
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
||||
});
|
||||
|
||||
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
||||
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
||||
await changesTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
||||
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(newTabPanel(sidePanel(page))).toBeVisible();
|
||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
|
||||
// Dragging the pane's only tab into its own left edge splits that tab into a
|
||||
// new pane and leaves the one it came from empty.
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
||||
await splitTabOutOfMainPane(page, draftTab);
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-pane_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const emptyMain = newTabPanel(mainPane(page));
|
||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
||||
|
||||
const newTabChip = mainPane(page)
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await newTabChip.hover();
|
||||
await mainPane(page)
|
||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
|
||||
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
||||
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(sidePanel(page)).toBeVisible();
|
||||
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Park a tab in the Side panel, then put the panel away. The workspace still
|
||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
||||
// nothing reseeds a draft into it.
|
||||
await ensureSidePanel(page);
|
||||
await newTabPanel(sidePanel(page))
|
||||
.getByRole("button", { name: "Files", exact: true })
|
||||
.click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await draftTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-draft-close-"]')
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Main now contains New and is the only pane the user can see. Closing its
|
||||
// tab cannot remove the final visible pane.
|
||||
const lastPane = newTabPanel(mainPane(page));
|
||||
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
||||
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
|
||||
const finalNewTab = mainPane(page)
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await finalNewTab.hover();
|
||||
await mainPane(page)
|
||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(lastPane).toBeVisible();
|
||||
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
||||
|
||||
await mainPane(page).getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-changes")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
||||
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openSidebarProjectFilter,
|
||||
pinWorkspaceFromSidebar,
|
||||
selectAllProjectsFilter,
|
||||
selectSidebarStatusGrouping,
|
||||
toggleProjectFilter,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
test.describe("Sidebar project filter", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("pins the sidebar to one project across both grouping modes", async ({ page }) => {
|
||||
// Two temp repos means two projects, which is also what makes the `Project ›` row appear.
|
||||
const alpha = await seedWorkspace({ repoPrefix: "project-filter-alpha-", title: "Alpha work" });
|
||||
const beta = await seedWorkspace({ repoPrefix: "project-filter-beta-", title: "Beta work" });
|
||||
const serverId = getServerId();
|
||||
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||
const betaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`);
|
||||
const filterTrigger = page.getByTestId("sidebar-display-project-filter");
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await expect(alphaRow).toBeVisible();
|
||||
await expect(betaRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// The indicator reads the filter as it is applied, so it must be on here.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(filterTrigger).toBeVisible();
|
||||
await expect(filterTrigger.getByTestId("menu-sub-indicator")).toBeVisible();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
// Status grouping builds its rows from the workspace entries rather than the projects
|
||||
// array, so a filter applied in only one of the two places passes every check above and
|
||||
// silently fails right here.
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(betaRow).toHaveCount(0);
|
||||
|
||||
// The filter is view state and survives a cold load.
|
||||
await page.reload();
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toHaveCount(0);
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await selectAllProjectsFilter(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(alphaRow).toBeVisible();
|
||||
await expect(betaRow).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await beta.cleanup();
|
||||
await alpha.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the display menu reachable when the pinned section swallows the filtered project", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Pinning hoists a chat out of its project, and a project whose chats are ALL hoisted is
|
||||
// dropped from the project list entirely. Filter to that project and the list body has no
|
||||
// project rows left — so the header, which carries the only route back to the filter page,
|
||||
// has to survive on the strength of the filter alone.
|
||||
const alpha = await seedWorkspace({
|
||||
repoPrefix: "project-filter-pinned-",
|
||||
title: "Pinned work",
|
||||
});
|
||||
const beta = await seedWorkspace({ repoPrefix: "project-filter-other-", title: "Other work" });
|
||||
const serverId = getServerId();
|
||||
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await pinWorkspaceFromSidebar(page, alpha.workspaceId);
|
||||
await expect(alphaRow).toBeVisible();
|
||||
|
||||
// The way out of the filter is still on screen.
|
||||
await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible();
|
||||
await openSidebarProjectFilter(page);
|
||||
await selectAllProjectsFilter(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await beta.cleanup();
|
||||
await alpha.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the filter row when there is only one project", async ({ page }) => {
|
||||
const only = await seedWorkspace({ repoPrefix: "project-filter-solo-", title: "Solo work" });
|
||||
const serverId = getServerId();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${only.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-display-project-filter")).toHaveCount(0);
|
||||
} finally {
|
||||
await only.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
@@ -142,6 +146,25 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pin-project-creation-",
|
||||
title: "Pinned workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
|
||||
@@ -351,7 +351,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
||||
beforeXterm.rows,
|
||||
);
|
||||
|
||||
// Split the pane downward. This focuses the new empty pane, so the terminal
|
||||
// Split the pane downward. This focuses the new tab pane, so the terminal
|
||||
// pane is unfocused at the exact moment its container shrinks.
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
||||
|
||||
|
||||
@@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
|
||||
// Workspaces always render a hidden explorer companion pane alongside the
|
||||
// main pane, so an unscoped testid locator matches both; scope to the
|
||||
// visible one.
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-terminal")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||
@@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
const COMPACT_VIEWPORT = { width: 390, height: 520 };
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function cloneToolCallWithId(
|
||||
message: WebSocketMessage,
|
||||
callId: string,
|
||||
sequenceOffset: number,
|
||||
): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { seq?: number; event?: { item?: { callId?: string } } } };
|
||||
};
|
||||
const payload = envelope.message?.payload;
|
||||
const item = payload?.event?.item;
|
||||
if (!payload || !item?.callId || typeof payload.seq !== "number") {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.callId = callId;
|
||||
payload.seq += sequenceOffset;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function shiftTimelineSequence(
|
||||
message: WebSocketMessage,
|
||||
sequenceOffset: number,
|
||||
): WebSocketMessage {
|
||||
if (sequenceOffset === 0) {
|
||||
return message;
|
||||
}
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { message?: { payload?: { seq?: number } } };
|
||||
const payload = envelope.message?.payload;
|
||||
if (typeof payload?.seq !== "number") {
|
||||
return message;
|
||||
}
|
||||
payload.seq += sequenceOffset;
|
||||
return JSON.stringify(envelope);
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let firstCompletedMessage: WebSocketMessage | null = null;
|
||||
let isHolding = false;
|
||||
let sequenceOffset = 0;
|
||||
const heldMessages: WebSocketMessage[] = [];
|
||||
let sendToPage: ((message: WebSocketMessage) => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
sendToPage = (message) => ws.send(message);
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (isHolding) {
|
||||
heldMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(shiftTimelineSequence(message, sequenceOffset));
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
firstCallId ??= toolCall?.callId ?? null;
|
||||
if (toolCall?.callId === firstCallId && toolCall.status === "completed") {
|
||||
firstCompletedMessage = message;
|
||||
isHolding = true;
|
||||
resolveFirstCompleted();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
release(extraCompletedCalls = 0) {
|
||||
isHolding = false;
|
||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||
throw new Error("Expected the first completed tool call before release");
|
||||
}
|
||||
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||
sendToPage?.(
|
||||
cloneToolCallWithId(
|
||||
firstCompletedMessage as WebSocketMessage,
|
||||
`${firstCallId}:${index}`,
|
||||
index + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
sequenceOffset = extraCompletedCalls;
|
||||
for (const message of heldMessages.splice(0)) {
|
||||
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function createOverviewAgent(page: Page, title: string) {
|
||||
await configureOverviewToolCalls(page);
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-overview-sheet-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
}
|
||||
|
||||
async function openOverviewAgent(
|
||||
page: Page,
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||
): Promise<void> {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
function hasScrolledToLatest(root: HTMLElement): boolean {
|
||||
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||
if (node.scrollHeight <= node.clientHeight) {
|
||||
continue;
|
||||
}
|
||||
const maximumScrollTop = node.scrollHeight - node.clientHeight;
|
||||
return maximumScrollTop > 0 && Math.abs(node.scrollTop - maximumScrollTop) <= 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readScrollMetrics(root: HTMLElement) {
|
||||
const badgeElements = root.querySelectorAll<HTMLElement>("[data-testid=tool-call-badge]");
|
||||
const latestBadge = badgeElements.item(badgeElements.length - 1);
|
||||
const scrollables = [];
|
||||
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||
if (node.scrollHeight <= node.clientHeight) {
|
||||
continue;
|
||||
}
|
||||
scrollables.push({
|
||||
className: node.className,
|
||||
clientHeight: node.clientHeight,
|
||||
rect: node.getBoundingClientRect().toJSON(),
|
||||
scrollHeight: node.scrollHeight,
|
||||
scrollTop: node.scrollTop,
|
||||
tagName: node.tagName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
latestBadgeRect: latestBadge?.getBoundingClientRect().toJSON(),
|
||||
rootRect: root.getBoundingClientRect().toJSON(),
|
||||
scrollables,
|
||||
viewport: { height: window.innerHeight, width: window.innerWidth },
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("compact overview tool calls", () => {
|
||||
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
|
||||
|
||||
test("keeps a live group sheet current and stacks individual details above it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Compact overview tool calls");
|
||||
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
|
||||
await gate.waitForFirstCompleted();
|
||||
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
await group.click();
|
||||
|
||||
const sheet = page.getByTestId("tool-call-group-sheet");
|
||||
const summary = page.getByTestId("tool-call-group-sheet-summary");
|
||||
await expect(sheet).toBeVisible();
|
||||
const initialSummary = await summary.innerText();
|
||||
|
||||
gate.release(10);
|
||||
const badges = sheet.getByTestId("tool-call-badge");
|
||||
await expect.poll(() => badges.count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(11);
|
||||
await expect(summary).not.toHaveText(initialSummary);
|
||||
await expect.poll(() => sheet.evaluate(hasScrolledToLatest)).toBe(true);
|
||||
const scrollMetrics = await sheet.evaluate(readScrollMetrics);
|
||||
await testInfo.attach("compact-overview-scroll-metrics", {
|
||||
body: JSON.stringify(scrollMetrics, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
await badges.last().click();
|
||||
const detailClose = page.getByTestId("tool-call-sheet-close");
|
||||
await expect(detailClose).toBeVisible();
|
||||
|
||||
await detailClose.click();
|
||||
await expect(detailClose).toBeHidden();
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await testInfo.attach("compact-overview-tool-call-group", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByTestId("tool-call-group-sheet-close").click();
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(group).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
try {
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
await group.click();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
.toBe(promptTitle);
|
||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||
await agentTab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -266,7 +266,7 @@ test.describe("Workspace labels", () => {
|
||||
|
||||
// Clear restores the complete sidebar without leaving the filter page.
|
||||
await page.getByTestId("sidebar-label-filter-clear").click();
|
||||
await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden();
|
||||
await expect(page.getByTestId("sidebar-filter-empty-state")).toBeHidden();
|
||||
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
|
||||
await expect(labelledRow).toBeVisible();
|
||||
await expect(unlabelledRow).toBeVisible();
|
||||
|
||||
@@ -65,12 +65,14 @@ test.describe("Workspace pane mounting", () => {
|
||||
.first()
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
2,
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
||||
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
closeSetupTab,
|
||||
waitForWorkspaceTabsVisible,
|
||||
expectFailedSetupTabSeededInMainPane,
|
||||
expectSetupTabNotSeeded,
|
||||
expectNoTerminalTabs,
|
||||
clickFirstTerminalTab,
|
||||
expectFirstTerminalTabContains,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
returnHomeFromWorkspace,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
@@ -39,7 +43,7 @@ interface WorkspaceScriptStarter {
|
||||
}
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
test("does not seed the setup tab while workspace setup is running", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
@@ -60,7 +64,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
@@ -99,6 +103,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
await expectSetupPanel(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
@@ -156,7 +162,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a failed setup snapshot when setup fails", async () => {
|
||||
test("seeds a failed setup tab once in the main pane", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
@@ -173,7 +179,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-failure",
|
||||
});
|
||||
@@ -182,6 +188,16 @@ test.describe("Workspace setup streaming", () => {
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await returnHomeFromWorkspace(page);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByTestId(
|
||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
||||
);
|
||||
const copyTerminalId = page.getByRole("menuitem", {
|
||||
name: /^Copy terminal id/,
|
||||
});
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
|
||||
@@ -196,6 +196,12 @@ async function loadHarnessEnvironment(repoRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") {
|
||||
if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) {
|
||||
throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const repoRoot = path.resolve(__dirname, "../../../..");
|
||||
await loadHarnessEnvironment(repoRoot);
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
|
||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||
}
|
||||
if (draft.notes !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
||||
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
|
||||
await expect(notesHint).toHaveText(
|
||||
"Helps agents choose this profile when starting another agent.",
|
||||
);
|
||||
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
|
||||
"1",
|
||||
);
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (field === "notes") {
|
||||
await expect(
|
||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
||||
).toHaveValue(value);
|
||||
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||
value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await expect(
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface DaemonHydrationGate {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface RewindCompletionGate {
|
||||
clearTimelineStreamCount(): void;
|
||||
release(): void;
|
||||
timelineStreamCount(): number;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PromptJumpRequestTracker {
|
||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||
}
|
||||
@@ -255,6 +262,62 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
|
||||
};
|
||||
}
|
||||
|
||||
export async function holdRewindCompletion(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<RewindCompletionGate> {
|
||||
let released = false;
|
||||
let timelineStreamCount = 0;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
const streamEvent = payload?.event;
|
||||
if (
|
||||
sessionMessage?.type === "agent_stream" &&
|
||||
payload?.agentId === agentId &&
|
||||
streamEvent &&
|
||||
typeof streamEvent === "object" &&
|
||||
(streamEvent as { type?: unknown }).type === "timeline"
|
||||
) {
|
||||
timelineStreamCount += 1;
|
||||
}
|
||||
if (
|
||||
!released &&
|
||||
sessionMessage?.type === "agent.rewind.response" &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
resolveDelayedResponse?.();
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
clearTimelineStreamCount() {
|
||||
timelineStreamCount = 0;
|
||||
},
|
||||
release() {
|
||||
released = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
timelineStreamCount: () => timelineStreamCount,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function fileReadRequest(message: WebSocketMessage, path: string): boolean {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
const session =
|
||||
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as { type?: unknown; mode?: unknown; path?: unknown })
|
||||
: (envelope as { type?: unknown; mode?: unknown; path?: unknown });
|
||||
return (
|
||||
session.type === "file_explorer_request" && session.mode === "file" && session.path === path
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function delayFileReadResponse(page: Page, path: string) {
|
||||
let delayed: (() => void) | null = null;
|
||||
let resolveDelayed: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayed = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
server.onMessage((message) => {
|
||||
ws.send(message);
|
||||
});
|
||||
// Hold the outgoing JSON request. The corresponding body response travels as
|
||||
// binary frames, so intercepting the response cannot identify this read.
|
||||
ws.onMessage((message) => {
|
||||
if (delayed || !fileReadRequest(message, path)) {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
delayed = () => server.send(message);
|
||||
resolveDelayed?.();
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitUntilHeld: () => delayedResponse,
|
||||
release: () => delayed?.(),
|
||||
};
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
|
||||
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
|
||||
/** Assert the New tab button is visible in the tab bar. */
|
||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
|
||||
/** Choose Agent from the pane-local `+` menu. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
await createAgentTabFromMenu(page);
|
||||
}
|
||||
|
||||
/** Open the new-tab menu and click "New terminal". */
|
||||
/** Choose Terminal from the pane-local `+` menu. */
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
const trigger = page
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = page
|
||||
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
|
||||
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
@@ -224,7 +221,7 @@ export async function expectTabTitleFits(
|
||||
}
|
||||
|
||||
export function terminalSurfaceLocator(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): Promise<MockAgentWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: options.repoPrefix,
|
||||
repo: options.repo,
|
||||
port: options.port,
|
||||
});
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
|
||||
@@ -105,7 +105,6 @@ export async function declineRelay(page: Page): Promise<void> {
|
||||
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
||||
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
|
||||
await enableButton.click();
|
||||
await expect(page.getByRole("button", { name: "Enabling...", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
|
||||
@@ -114,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
||||
export async function expectPairingOffer(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||
await expect(
|
||||
page.getByRole("alert").filter({
|
||||
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -29,10 +30,10 @@ async function assertCheckPill(page: Page, testId: string, count: number): Promi
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: { passed: number; failed: number; pending: number },
|
||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { CDPSession, Page } from "@playwright/test";
|
||||
|
||||
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
|
||||
const REPLICA_CACHE_ROWS_STORE = "rows";
|
||||
const PERSIST_FRAME_NAMES = [
|
||||
"captureSessions",
|
||||
"captureHost",
|
||||
"queueReplicaChanges",
|
||||
"apply",
|
||||
"safeParse",
|
||||
"stringify",
|
||||
"setItem",
|
||||
"measuredPut",
|
||||
] as const;
|
||||
const PERSIST_STACK_ANCHORS = new Set([
|
||||
"captureSessions",
|
||||
"captureHost",
|
||||
"queueReplicaChanges",
|
||||
"persist",
|
||||
"flushPending",
|
||||
"measuredPut",
|
||||
]);
|
||||
|
||||
export interface ReplicaCacheMeasurementReport {
|
||||
targetUrl: string;
|
||||
observationMs: number;
|
||||
persistCpuMs: number;
|
||||
persistCpuFrames: Record<string, number>;
|
||||
flushes: number;
|
||||
bytesWritten: {
|
||||
total: number;
|
||||
perFlush: number[];
|
||||
};
|
||||
redundantWrites: number;
|
||||
storageDurationMs: number;
|
||||
longTasks: {
|
||||
count: number;
|
||||
totalDurationMs: number;
|
||||
maximumDurationMs: number;
|
||||
durationsMs: number[];
|
||||
};
|
||||
restoreMs: number | null;
|
||||
}
|
||||
|
||||
interface BrowserMeasurementState {
|
||||
flushes: Array<{ bytes: number; durationMs: number }>;
|
||||
longTasks: number[];
|
||||
redundantWrites: number;
|
||||
}
|
||||
|
||||
interface ProfileNode {
|
||||
id: number;
|
||||
callFrame: { functionName: string; url: string };
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface SamplingProfile {
|
||||
nodes: ProfileNode[];
|
||||
samples?: number[];
|
||||
timeDeltas?: number[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCacheMeasurement?: BrowserMeasurementState;
|
||||
__replicaCacheRestoreReadStartedAt?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName }) => {
|
||||
const encoder = new TextEncoder();
|
||||
const transactionWrites = new WeakMap<
|
||||
IDBTransaction,
|
||||
Array<
|
||||
| { type: "put"; key: string; value: string; bytes: number; startedAt: number }
|
||||
| { type: "delete"; key: string; bytes: 0; startedAt: number }
|
||||
>
|
||||
>();
|
||||
const lastValues = new Map<string, string>();
|
||||
|
||||
const resolveStorageKey = (
|
||||
store: IDBObjectStore,
|
||||
value: unknown,
|
||||
explicitKey: IDBValidKey | undefined,
|
||||
): unknown => {
|
||||
if (explicitKey !== undefined) return explicitKey;
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
if (Array.isArray(store.keyPath)) {
|
||||
return store.keyPath.map((part) => Reflect.get(value, part));
|
||||
}
|
||||
return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null;
|
||||
};
|
||||
|
||||
window.__replicaCacheMeasurement = {
|
||||
flushes: [],
|
||||
longTasks: [],
|
||||
redundantWrites: 0,
|
||||
};
|
||||
|
||||
const observeTransaction = (transaction: IDBTransaction) => {
|
||||
let writes = transactionWrites.get(transaction);
|
||||
if (writes) return writes;
|
||||
writes = [];
|
||||
transactionWrites.set(transaction, writes);
|
||||
transaction.addEventListener("complete", () => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state || writes!.length === 0) return;
|
||||
let bytes = 0;
|
||||
let startedAt = Number.POSITIVE_INFINITY;
|
||||
for (const write of writes!) {
|
||||
bytes += write.bytes;
|
||||
startedAt = Math.min(startedAt, write.startedAt);
|
||||
if (write.type === "put") {
|
||||
if (lastValues.get(write.key) === write.value) state.redundantWrites += 1;
|
||||
lastValues.set(write.key, write.value);
|
||||
} else {
|
||||
lastValues.delete(write.key);
|
||||
}
|
||||
}
|
||||
state.flushes.push({ bytes, durationMs: performance.now() - startedAt });
|
||||
});
|
||||
return writes;
|
||||
};
|
||||
|
||||
const originalPut = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
|
||||
const request =
|
||||
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
|
||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
||||
const rowPayload =
|
||||
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
|
||||
let serialized = JSON.stringify(value);
|
||||
if (typeof value === "string") serialized = value;
|
||||
if (typeof rowPayload === "string") serialized = rowPayload;
|
||||
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
|
||||
observeTransaction(this.transaction).push({
|
||||
type: "put",
|
||||
key: `${this.name}:${storageKey}`,
|
||||
value: serialized,
|
||||
bytes: encoder.encode(serialized).byteLength,
|
||||
startedAt: performance.now(),
|
||||
});
|
||||
return request;
|
||||
};
|
||||
const originalDelete = IDBObjectStore.prototype.delete;
|
||||
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
|
||||
const request = originalDelete.call(this, query);
|
||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
||||
observeTransaction(this.transaction).push({
|
||||
type: "delete",
|
||||
key: `${this.name}:${JSON.stringify(query)}`,
|
||||
bytes: 0,
|
||||
startedAt: performance.now(),
|
||||
});
|
||||
return request;
|
||||
};
|
||||
|
||||
const markRestoreRead = (store: IDBObjectStore) => {
|
||||
if (
|
||||
store.transaction.db.name === databaseName &&
|
||||
store.name === storeName &&
|
||||
store.transaction.mode === "readonly" &&
|
||||
window.__replicaCacheRestoreReadStartedAt === undefined
|
||||
) {
|
||||
window.__replicaCacheRestoreReadStartedAt = performance.now();
|
||||
}
|
||||
};
|
||||
const originalGet = IDBObjectStore.prototype.get;
|
||||
IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) {
|
||||
markRestoreRead(this);
|
||||
return originalGet.call(this, query);
|
||||
};
|
||||
const originalGetAll = IDBObjectStore.prototype.getAll;
|
||||
IDBObjectStore.prototype.getAll = function measuredGetAll(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
count?: number,
|
||||
) {
|
||||
markRestoreRead(this);
|
||||
return originalGetAll.call(this, query, count);
|
||||
};
|
||||
const originalOpenCursor = IDBObjectStore.prototype.openCursor;
|
||||
IDBObjectStore.prototype.openCursor = function measuredOpenCursor(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
direction?: IDBCursorDirection,
|
||||
) {
|
||||
markRestoreRead(this);
|
||||
return originalOpenCursor.call(this, query, direction);
|
||||
};
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
const observer = new PerformanceObserver((entries) => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) return;
|
||||
for (const entry of entries.getEntries()) state.longTasks.push(entry.duration);
|
||||
});
|
||||
try {
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// Chromium exposes Long Tasks; leave an empty list on runtimes that do not.
|
||||
}
|
||||
}
|
||||
},
|
||||
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheObservation(
|
||||
page: Page,
|
||||
): Promise<{ cdp: CDPSession; startedAt: number }> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
||||
state.flushes = [];
|
||||
state.longTasks = [];
|
||||
state.redundantWrites = 0;
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Profiler.enable");
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
|
||||
await cdp.send("Profiler.start");
|
||||
return { cdp, startedAt: Date.now() };
|
||||
}
|
||||
|
||||
export async function finishReplicaCacheObservation(
|
||||
page: Page,
|
||||
observation: { cdp: CDPSession; startedAt: number },
|
||||
targetUrl: string,
|
||||
): Promise<Omit<ReplicaCacheMeasurementReport, "restoreMs">> {
|
||||
const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile };
|
||||
await observation.cdp.detach();
|
||||
const browser = await page.evaluate(() => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
||||
return state;
|
||||
});
|
||||
const cpu = summarizePersistCpu(result.profile);
|
||||
const bytesPerFlush = browser.flushes.map((flush) => flush.bytes);
|
||||
const longTaskDurations = browser.longTasks;
|
||||
return {
|
||||
targetUrl,
|
||||
observationMs: Date.now() - observation.startedAt,
|
||||
persistCpuMs: cpu.totalMs,
|
||||
persistCpuFrames: cpu.byFrame,
|
||||
flushes: browser.flushes.length,
|
||||
bytesWritten: {
|
||||
total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0),
|
||||
perFlush: bytesPerFlush,
|
||||
},
|
||||
redundantWrites: browser.redundantWrites,
|
||||
storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0),
|
||||
longTasks: {
|
||||
count: longTaskDurations.length,
|
||||
totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
|
||||
maximumDurationMs: Math.max(0, ...longTaskDurations),
|
||||
durationsMs: longTaskDurations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function measureReplicaCacheRestore(
|
||||
page: Page,
|
||||
targetUrl: string,
|
||||
hydratedSelector: string,
|
||||
): Promise<number | null> {
|
||||
await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => {
|
||||
await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" });
|
||||
});
|
||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
|
||||
const restoredAt = await page.waitForFunction(
|
||||
(selector) => {
|
||||
const startedAt = window.__replicaCacheRestoreReadStartedAt;
|
||||
if (startedAt === undefined) return null;
|
||||
const element = document.querySelector(selector);
|
||||
if (!element || !(element instanceof HTMLElement) || element.offsetParent === null)
|
||||
return null;
|
||||
return performance.now();
|
||||
},
|
||||
hydratedSelector,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const restoredAtMs = await restoredAt.jsonValue();
|
||||
const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null);
|
||||
return typeof restoredAtMs === "number" && startedAtMs !== null
|
||||
? restoredAtMs - startedAtMs
|
||||
: null;
|
||||
}
|
||||
|
||||
function summarizePersistCpu(profile: SamplingProfile): {
|
||||
totalMs: number;
|
||||
byFrame: Record<string, number>;
|
||||
} {
|
||||
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
||||
const byFrame: Record<string, number> = {};
|
||||
let totalMicroseconds = 0;
|
||||
for (const [index, sampleId] of (profile.samples ?? []).entries()) {
|
||||
const delta = profile.timeDeltas?.[index] ?? 0;
|
||||
const stackFunctions = new Set<string>();
|
||||
let node = nodes.get(sampleId);
|
||||
while (node) {
|
||||
stackFunctions.add(node.callFrame.functionName);
|
||||
node = node.parent === undefined ? undefined : nodes.get(node.parent);
|
||||
}
|
||||
if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue;
|
||||
const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name));
|
||||
if (matched.length === 0) continue;
|
||||
totalMicroseconds += delta;
|
||||
for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000;
|
||||
}
|
||||
return { totalMs: totalMicroseconds / 1_000, byFrame };
|
||||
}
|
||||
@@ -1,26 +1,49 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const DATABASE_NAME = "paseo-replica-cache";
|
||||
const STORE_NAME = "key-value";
|
||||
const STORAGE_KEY = "@paseo:replica-cache";
|
||||
const DATABASE_NAME = "paseo-replica-row-store";
|
||||
const STORE_NAME = "rows";
|
||||
const SINGLETON_ID = "singleton";
|
||||
|
||||
type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint";
|
||||
|
||||
interface ReplicaRowRecord {
|
||||
serverId: string;
|
||||
kind: ReplicaRowKind;
|
||||
id: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface ReplicaCacheHostRecord {
|
||||
serverId: string;
|
||||
agents: Array<Record<string, unknown>>;
|
||||
workspaces: Array<Record<string, unknown>>;
|
||||
projects: Array<Record<string, unknown>>;
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
range?: {
|
||||
endSeq?: number;
|
||||
epoch?: string;
|
||||
startSeq?: number;
|
||||
} | null;
|
||||
} | null;
|
||||
directorySync?: unknown;
|
||||
}
|
||||
|
||||
export interface ReplicaCacheRecord {
|
||||
version?: number;
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
range?: {
|
||||
endSeq?: number;
|
||||
epoch?: string;
|
||||
startSeq?: number;
|
||||
} | null;
|
||||
} | null;
|
||||
}>;
|
||||
hosts?: ReplicaCacheHostRecord[];
|
||||
}
|
||||
|
||||
interface ReplicaCacheWriteObserverState {
|
||||
lastValue: string | null;
|
||||
lastValues: Record<string, string>;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
interface ReplicaCacheWriteObserverReport {
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
@@ -33,11 +56,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
async function readStoredValue(input: {
|
||||
async function readRows(input: {
|
||||
databaseName: string;
|
||||
storeName: string;
|
||||
storageKey: string;
|
||||
}): Promise<string | null> {
|
||||
}): Promise<ReplicaRowRecord[]> {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(input.databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
@@ -47,26 +69,103 @@ async function readStoredValue(input: {
|
||||
const request = database
|
||||
.transaction(input.storeName, "readonly")
|
||||
.objectStore(input.storeName)
|
||||
.get(input.storageKey);
|
||||
request.addEventListener("success", () =>
|
||||
resolve(typeof request.result === "string" ? request.result : null),
|
||||
);
|
||||
.getAll();
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
|
||||
const hosts = new Map<string, ReplicaCacheHostRecord>();
|
||||
for (const row of rows) {
|
||||
const host = hosts.get(row.serverId) ?? {
|
||||
serverId: row.serverId,
|
||||
agents: [],
|
||||
workspaces: [],
|
||||
projects: [],
|
||||
timeline: null,
|
||||
};
|
||||
const payload = JSON.parse(row.payload) as Record<string, unknown>;
|
||||
switch (row.kind) {
|
||||
case "agent":
|
||||
host.agents.push(payload);
|
||||
break;
|
||||
case "workspace":
|
||||
host.workspaces.push(payload);
|
||||
break;
|
||||
case "project":
|
||||
host.projects.push(payload);
|
||||
break;
|
||||
case "timeline":
|
||||
host.timeline = payload;
|
||||
break;
|
||||
case "checkpoint":
|
||||
host.directorySync = payload;
|
||||
break;
|
||||
}
|
||||
hosts.set(row.serverId, host);
|
||||
}
|
||||
return { version: 6, hosts: [...hosts.values()] };
|
||||
}
|
||||
|
||||
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
|
||||
const raw = await page.evaluate(readStoredValue, {
|
||||
const rows = await page.evaluate(readRows, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
|
||||
return rows.length > 0 ? assembleCache(rows) : null;
|
||||
}
|
||||
|
||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||
const rows: ReplicaRowRecord[] = [];
|
||||
for (const host of value.hosts ?? []) {
|
||||
for (const agent of host.agents) {
|
||||
const snapshot = agent.snapshot;
|
||||
if (!snapshot || typeof snapshot !== "object") continue;
|
||||
const id = Reflect.get(snapshot, "id");
|
||||
if (typeof id === "string") {
|
||||
rows.push({ serverId: host.serverId, kind: "agent", id, payload: JSON.stringify(agent) });
|
||||
}
|
||||
}
|
||||
for (const workspace of host.workspaces) {
|
||||
if (typeof workspace.id === "string") {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "workspace",
|
||||
id: workspace.id,
|
||||
payload: JSON.stringify(workspace),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const project of host.projects) {
|
||||
if (typeof project.projectId === "string") {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "project",
|
||||
id: project.projectId,
|
||||
payload: JSON.stringify(project),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (host.timeline) {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "timeline",
|
||||
id: SINGLETON_ID,
|
||||
payload: JSON.stringify(host.timeline),
|
||||
});
|
||||
}
|
||||
if (host.directorySync !== undefined) {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "checkpoint",
|
||||
id: SINGLETON_ID,
|
||||
payload: JSON.stringify(host.directorySync),
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(
|
||||
async ({ databaseName, storeName, storageKey, raw }) => {
|
||||
async ({ databaseName, storeName, nextRows }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
@@ -74,26 +173,23 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, "readwrite");
|
||||
transaction.objectStore(storeName).put(raw, storageKey);
|
||||
const store = transaction.objectStore(storeName);
|
||||
store.clear();
|
||||
for (const row of nextRows) store.put(row);
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
});
|
||||
},
|
||||
{
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
raw: JSON.stringify(value),
|
||||
},
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, nextRows: rows },
|
||||
);
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName, storageKey }) => {
|
||||
({ databaseName, storeName }) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: null,
|
||||
lastValues: {},
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
@@ -106,51 +202,71 @@ export async function observeReplicaCacheStorageWrites(page: Page): Promise<void
|
||||
if (
|
||||
this.transaction.db.name !== databaseName ||
|
||||
this.name !== storeName ||
|
||||
key !== storageKey ||
|
||||
typeof value !== "string"
|
||||
!value ||
|
||||
typeof value !== "object"
|
||||
) {
|
||||
return request;
|
||||
}
|
||||
const serverId = Reflect.get(value, "serverId");
|
||||
const kind = Reflect.get(value, "kind");
|
||||
const id = Reflect.get(value, "id");
|
||||
const payload = Reflect.get(value, "payload");
|
||||
if (
|
||||
typeof serverId !== "string" ||
|
||||
typeof kind !== "string" ||
|
||||
typeof id !== "string" ||
|
||||
typeof payload !== "string"
|
||||
) {
|
||||
return request;
|
||||
}
|
||||
const rowKey = `${serverId}:${kind}:${id}`;
|
||||
const startedAt = performance.now();
|
||||
request.addEventListener("success", () => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) return;
|
||||
if (state.lastValue === value) state.redundantWrites += 1;
|
||||
state.lastValue = value;
|
||||
if (state.lastValues[rowKey] === payload) state.redundantWrites += 1;
|
||||
state.lastValues[rowKey] = payload;
|
||||
state.writes += 1;
|
||||
state.serializedChars += value.length;
|
||||
state.serializedChars += payload.length;
|
||||
state.storageDurationMs += performance.now() - startedAt;
|
||||
});
|
||||
return request;
|
||||
};
|
||||
},
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
|
||||
const lastValue = await page.evaluate(readStoredValue, {
|
||||
const rows = await page.evaluate(readRows, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
await page.evaluate((value) => {
|
||||
const lastValues = Object.fromEntries(
|
||||
rows.map((row) => [`${row.serverId}:${row.kind}:${row.id}`, row.payload]),
|
||||
);
|
||||
await page.evaluate((values) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: value,
|
||||
lastValues: values,
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
}, lastValue);
|
||||
}, lastValues);
|
||||
}
|
||||
|
||||
export async function readReplicaCacheStorageWriteObserver(
|
||||
page: Page,
|
||||
): Promise<ReplicaCacheWriteObserverState> {
|
||||
): Promise<ReplicaCacheWriteObserverReport> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) throw new Error("Replica cache write observer is not installed");
|
||||
return state;
|
||||
return {
|
||||
redundantWrites: state.redundantWrites,
|
||||
serializedChars: state.serializedChars,
|
||||
storageDurationMs: state.storageDurationMs,
|
||||
writes: state.writes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,27 @@ export async function openSidebarDisplayPage(page: Page, branchTestID: string):
|
||||
await page.getByTestId(branchTestID).click();
|
||||
}
|
||||
|
||||
// Project filters live a page below the display-preferences root, like the host filters.
|
||||
export async function openSidebarProjectFilter(page: Page): Promise<void> {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-project-filter");
|
||||
}
|
||||
|
||||
// The filter rows are keyed by the project's sidebar view key. A project seeded from its own temp
|
||||
// repo has a `projectKey` unique on the host, so the view key IS that key — see
|
||||
// `buildWorkspaceStructureProjects`.
|
||||
export async function toggleProjectFilter(page: Page, projectViewKey: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllProjectsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-project-filter-all").click();
|
||||
}
|
||||
|
||||
export async function closeSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-grouping");
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
|
||||
@@ -13,12 +13,12 @@ function splitSeparator(page: Page): Locator {
|
||||
}
|
||||
|
||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByRole("button", { name: "Open side panel" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close explorer" }).click();
|
||||
await page.getByRole("button", { name: "Close side panel" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { summarizeStreamSmoothness } from "./stream-smoothness";
|
||||
|
||||
describe("summarizeStreamSmoothness", () => {
|
||||
it("reports canonical handover snaps separately from paced growth", () => {
|
||||
const report = summarizeStreamSmoothness(
|
||||
[
|
||||
{ timestampMs: 0, growthChars: 0, firstPaintChars: 0 },
|
||||
{ timestampMs: 10, growthChars: 4, firstPaintChars: 0 },
|
||||
{ timestampMs: 20, growthChars: 100, firstPaintChars: 30 },
|
||||
{ timestampMs: 30, growthChars: 4, firstPaintChars: 0 },
|
||||
{ timestampMs: 40, growthChars: 0, firstPaintChars: 0 },
|
||||
],
|
||||
40,
|
||||
);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
frames: 3,
|
||||
growthFrames: 2,
|
||||
charsPainted: 8,
|
||||
maxCharsInOneFrame: 4,
|
||||
firstPaintFrames: 1,
|
||||
firstPaintChars: 30,
|
||||
handoverGrowthFrames: 1,
|
||||
handoverGrowthChars: 100,
|
||||
maxHandoverGrowthCharsInOneFrame: 100,
|
||||
updateGapP95Ms: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Frame-level sampling of the streaming assistant message, and the two numbers
|
||||
* that describe how it reads:
|
||||
*
|
||||
* - `charsPerFrameCv` — coefficient of variation of characters painted per frame.
|
||||
* This is the smoothness metric. Painting deltas as they land tracks the
|
||||
* arrival lumps, so it is high; pacing the reveal flattens it.
|
||||
* - `updateGapP95Ms` — how long the text can sit unchanged mid-stream. This is
|
||||
* the stall metric, and it is what stops smoothness being bought with lag.
|
||||
*
|
||||
* Both are needed. A stream that never updates is perfectly smooth.
|
||||
*/
|
||||
|
||||
export interface StreamFrameSample {
|
||||
timestampMs: number;
|
||||
growthChars: number;
|
||||
firstPaintChars: number;
|
||||
}
|
||||
|
||||
export interface StreamSmoothnessReport {
|
||||
sampleWindowMs: number;
|
||||
frames: number;
|
||||
growthFrames: number;
|
||||
charsPainted: number;
|
||||
meanCharsPerFrame: number;
|
||||
maxCharsInOneFrame: number;
|
||||
charsPerFrameCv: number;
|
||||
firstPaintFrames: number;
|
||||
firstPaintChars: number;
|
||||
maxFirstPaintCharsInOneFrame: number;
|
||||
handoverGrowthFrames: number;
|
||||
handoverGrowthChars: number;
|
||||
maxHandoverGrowthCharsInOneFrame: number;
|
||||
updateGapP50Ms: number;
|
||||
updateGapP95Ms: number;
|
||||
updateGapMaxMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record text growth on every mounted assistant message once per frame.
|
||||
*
|
||||
* Message occurrence identity separates paced growth from first paint. Completed markdown
|
||||
* blocks move into canonical timeline rows while a turn streams, remounting text
|
||||
* that was already visible. Counting the new node's full length as growth would
|
||||
* report that handover as a large reveal even though no text appeared on screen.
|
||||
* First paints stay in the report as a separate diagnostic.
|
||||
*
|
||||
* Runs entirely in the page so the sampling clock is the same clock the reveal
|
||||
* runs on. Waits for the text to start moving first, so a slow model's
|
||||
* time-to-first-token is not counted as a stall.
|
||||
*/
|
||||
export async function sampleStreamFrames(
|
||||
page: Page,
|
||||
options: { sampleWindowMs: number; startTimeoutMs?: number },
|
||||
): Promise<StreamFrameSample[]> {
|
||||
const startTimeoutMs = options.startTimeoutMs ?? 60_000;
|
||||
return await page.evaluate(
|
||||
async ({ sampleWindowMs, startTimeoutMs: waitMs }) => {
|
||||
const readLength = () => {
|
||||
let total = 0;
|
||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
||||
total += node.textContent?.length ?? 0;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const waitStart = performance.now();
|
||||
const base = readLength();
|
||||
await new Promise<void>((resolve) => {
|
||||
const poll = () => {
|
||||
if (readLength() > base + 20 || performance.now() - waitStart > waitMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(poll);
|
||||
};
|
||||
requestAnimationFrame(poll);
|
||||
});
|
||||
|
||||
let previousLengths = new Map<string, number>();
|
||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
||||
const key = node.getAttribute("data-reveal-key");
|
||||
const length = Number(node.getAttribute("data-reveal-length"));
|
||||
if (key !== null && Number.isFinite(length)) {
|
||||
previousLengths.set(key, length);
|
||||
}
|
||||
}
|
||||
|
||||
const samples: Array<{
|
||||
timestampMs: number;
|
||||
growthChars: number;
|
||||
firstPaintChars: number;
|
||||
}> = [];
|
||||
await new Promise<void>((resolve) => {
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const currentLengths = new Map<string, number>();
|
||||
let growthChars = 0;
|
||||
let firstPaintChars = 0;
|
||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
||||
const key = node.getAttribute("data-reveal-key");
|
||||
const currentLength = Number(node.getAttribute("data-reveal-length"));
|
||||
if (key === null || !Number.isFinite(currentLength)) {
|
||||
continue;
|
||||
}
|
||||
const previousLength = previousLengths.get(key);
|
||||
if (previousLength === undefined || currentLength < previousLength) {
|
||||
firstPaintChars += currentLength;
|
||||
} else {
|
||||
const nodeGrowth = currentLength - previousLength;
|
||||
growthChars += nodeGrowth;
|
||||
}
|
||||
currentLengths.set(key, currentLength);
|
||||
}
|
||||
samples.push({
|
||||
timestampMs: now,
|
||||
growthChars,
|
||||
firstPaintChars,
|
||||
});
|
||||
previousLengths = currentLengths;
|
||||
if (now - start >= sampleWindowMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
return samples;
|
||||
},
|
||||
{ sampleWindowMs: options.sampleWindowMs, startTimeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
export function computePercentile(samples: readonly number[], percentile: number): number {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return sorted[Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1)];
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce frame samples to the report. Frames outside the streaming window are
|
||||
* dropped: leading idle is time-to-first-token, and trailing idle means the turn
|
||||
* ended mid-sample. First-paint frames are structural handovers. Existing blocks
|
||||
* may also snap complete on those frames, so their growth is reported separately
|
||||
* from the paced-growth CV.
|
||||
*/
|
||||
export function summarizeStreamSmoothness(
|
||||
samples: readonly StreamFrameSample[],
|
||||
sampleWindowMs: number,
|
||||
): StreamSmoothnessReport {
|
||||
const deltas: number[] = [];
|
||||
const gapsMs: number[] = [];
|
||||
let lastUpdateAtMs: number | null = null;
|
||||
let firstGrowth = -1;
|
||||
let lastGrowth = -1;
|
||||
const handoverGrowths: number[] = [];
|
||||
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const sample = samples[index];
|
||||
const isHandover = sample.firstPaintChars > 0;
|
||||
const growth = isHandover ? 0 : sample.growthChars;
|
||||
if (isHandover && sample.growthChars > 0) {
|
||||
handoverGrowths.push(sample.growthChars);
|
||||
lastUpdateAtMs = sample.timestampMs;
|
||||
}
|
||||
deltas.push(growth);
|
||||
if (growth > 0) {
|
||||
if (firstGrowth === -1) {
|
||||
firstGrowth = deltas.length - 1;
|
||||
}
|
||||
lastGrowth = deltas.length - 1;
|
||||
if (lastUpdateAtMs !== null) {
|
||||
gapsMs.push(samples[index].timestampMs - lastUpdateAtMs);
|
||||
}
|
||||
lastUpdateAtMs = samples[index].timestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
const active = firstGrowth === -1 ? [] : deltas.slice(firstGrowth, lastGrowth + 1);
|
||||
const charsPainted = active.reduce((sum, delta) => sum + delta, 0);
|
||||
const mean = active.length > 0 ? charsPainted / active.length : 0;
|
||||
const variance =
|
||||
active.length > 0
|
||||
? active.reduce((sum, delta) => sum + (delta - mean) ** 2, 0) / active.length
|
||||
: 0;
|
||||
const firstPaints = samples.map((sample) => sample.firstPaintChars).filter((chars) => chars > 0);
|
||||
|
||||
return {
|
||||
sampleWindowMs,
|
||||
frames: active.length,
|
||||
growthFrames: active.filter((delta) => delta > 0).length,
|
||||
charsPainted,
|
||||
meanCharsPerFrame: round2(mean),
|
||||
maxCharsInOneFrame: active.length > 0 ? Math.max(...active) : 0,
|
||||
charsPerFrameCv: mean === 0 ? Number.POSITIVE_INFINITY : round2(Math.sqrt(variance) / mean),
|
||||
firstPaintFrames: firstPaints.length,
|
||||
firstPaintChars: firstPaints.reduce((sum, chars) => sum + chars, 0),
|
||||
maxFirstPaintCharsInOneFrame: firstPaints.length > 0 ? Math.max(...firstPaints) : 0,
|
||||
handoverGrowthFrames: handoverGrowths.length,
|
||||
handoverGrowthChars: handoverGrowths.reduce((sum, chars) => sum + chars, 0),
|
||||
maxHandoverGrowthCharsInOneFrame: handoverGrowths.length > 0 ? Math.max(...handoverGrowths) : 0,
|
||||
updateGapP50Ms: round2(computePercentile(gapsMs, 50)),
|
||||
updateGapP95Ms: round2(computePercentile(gapsMs, 95)),
|
||||
updateGapMaxMs: gapsMs.length > 0 ? round2(Math.max(...gapsMs)) : 0,
|
||||
};
|
||||
}
|
||||
@@ -532,30 +532,42 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
|
||||
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
for (let step = 0; step < 60; step += 1) {
|
||||
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
break;
|
||||
}
|
||||
await page.mouse.wheel(0, -1_000);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
}),
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
|
||||
}
|
||||
|
||||
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
|
||||
}
|
||||
|
||||
async function returnTimelineToSettledHistoryStart(
|
||||
page: Page,
|
||||
moveUp: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const loadingSpinner = page.getByTestId("load-older-history-spinner");
|
||||
// A caller may only start a new history traversal after its prior page has settled.
|
||||
await expect(loadingSpinner).toBeHidden();
|
||||
let previous = await readTimelineViewport(page);
|
||||
while (true) {
|
||||
await moveUp();
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
let viewport = await readTimelineViewport(page);
|
||||
// Tests that hold the response need to observe the requested page before releasing it.
|
||||
if (await loadingSpinner.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
await expect(loadingSpinner).toBeHidden();
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
viewport = await readTimelineViewport(page);
|
||||
expect(
|
||||
viewport.scrollTop < previous.scrollTop || viewport.scrollHeight > previous.scrollHeight,
|
||||
).toBe(true);
|
||||
previous = viewport;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
||||
|
||||
@@ -276,6 +276,11 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
});
|
||||
}
|
||||
|
||||
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -13,14 +13,52 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function setupTabTestId(workspaceId: string): string {
|
||||
return `workspace-tab-setup_${workspaceId}`;
|
||||
}
|
||||
|
||||
async function waitForSetupToReachWorkspace(page: Page): Promise<void> {
|
||||
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
|
||||
await expect(actionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await actionsButton.click();
|
||||
await expect(page.getByTestId("workspace-header-show-setup")).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectSetupTabNotSeeded(page: Page, workspaceId: string): Promise<void> {
|
||||
await waitForSetupToReachWorkspace(page);
|
||||
const tab = page.getByTestId(setupTabTestId(workspaceId));
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await expect(tab).toHaveCount(0);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectFailedSetupTabSeededInMainPane(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const tabId = setupTabTestId(workspaceId);
|
||||
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const panel = await ensureSidePanel(page);
|
||||
await expect(panel.getByTestId(tabId)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
||||
const tabId = setupTabTestId(workspaceId);
|
||||
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-setup_${workspaceId}-close`).click();
|
||||
await expect(page.getByTestId(tabId)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function explorerPaneTabRow(page: Page) {
|
||||
return visibleTestId(page, "workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
function sidePanel(page: Page) {
|
||||
return visibleTestId(page, "workspace-side-panel").first();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
@@ -32,35 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function ensureExplorerPane(page: Page) {
|
||||
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
|
||||
export async function ensureSidePanel(page: Page): Promise<Locator> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const tabRow = explorerPaneTabRow(page);
|
||||
if ((await tabRow.count()) === 0) {
|
||||
const panel = sidePanel(page);
|
||||
if ((await panel.count()) === 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
||||
return tabRow;
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveals the Side panel and brings one of its views up in it. Goes through the
|
||||
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
|
||||
*/
|
||||
async function openSidePanelView(
|
||||
page: Page,
|
||||
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
|
||||
): Promise<void> {
|
||||
const panel = await ensureSidePanel(page);
|
||||
const tab = panel.getByTestId(view.tabTestId);
|
||||
if ((await tab.count()) === 0) {
|
||||
await panel.getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
timeout: view.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-working_diff",
|
||||
launchItemId: "changes",
|
||||
contentTestId: "working-diff-panel",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
||||
if ((await filesTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(filesTab);
|
||||
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-files",
|
||||
launchItemId: "files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,28 +128,26 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
||||
if ((await pullRequestTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(pullRequestTab);
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-pull_request",
|
||||
launchItemId: "pull-request",
|
||||
contentTestId: "pr-pane",
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the `+` menu in the tab row and pick "New agent". */
|
||||
/** Open the pane-local `+` menu and pick Agent. */
|
||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||
const stableIosBuildSlot = 999;
|
||||
const FDROID_ABI_VERSION_CODE_SUFFIXES = {
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
x86: 3,
|
||||
x86_64: 4,
|
||||
};
|
||||
|
||||
function getNativeReleaseVersion(version) {
|
||||
const match = versionPattern.exec(version);
|
||||
@@ -21,7 +27,11 @@ function getNativeReleaseVersion(version) {
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
if (
|
||||
!Number.isSafeInteger(versionCode) ||
|
||||
versionCode <= 0 ||
|
||||
versionCode * 10 + 9 > 2_100_000_000
|
||||
) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
@@ -38,4 +48,16 @@ function getNativeReleaseVersion(version) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getNativeReleaseVersion };
|
||||
function getFdroidVersionCodes(version) {
|
||||
const { androidVersionCode } = getNativeReleaseVersion(version);
|
||||
return Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES).map(([abi, suffix]) => ({
|
||||
abi,
|
||||
versionCode: androidVersionCode * 10 + suffix,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const {
|
||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
} = require("./native-release-version");
|
||||
|
||||
describe("native release version", () => {
|
||||
it("reserves the final iOS build slot for a stable release", () => {
|
||||
@@ -24,4 +28,19 @@ describe("native release version", () => {
|
||||
"iOS beta number must be between 1 and 998",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives one F-Droid version code per published ABI", () => {
|
||||
expect(FDROID_ABI_VERSION_CODE_SUFFIXES).toEqual({
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
x86: 3,
|
||||
x86_64: 4,
|
||||
});
|
||||
expect(getFdroidVersionCodes("0.5.0")).toEqual([
|
||||
{ abi: "armeabi-v7a", versionCode: 50001 },
|
||||
{ abi: "arm64-v8a", versionCode: 50002 },
|
||||
{ abi: "x86", versionCode: 50003 },
|
||||
{ abi: "x86_64", versionCode: 50004 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.3",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project=browser",
|
||||
"test:e2e:diff-performance": "cross-env PASEO_DIFF_PERF_E2E=1 playwright test --project=browser e2e/browser/diff-performance.spec.ts",
|
||||
"test:e2e:stream-smoothness": "cross-env PASEO_AGENT_STREAM_PERF_E2E=1 playwright test --project=browser e2e/browser/agent-stream-smoothness.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
@@ -95,6 +96,7 @@
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
@@ -136,6 +138,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "~22.19.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
@@ -149,6 +152,7 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
||||
const { FDROID_ABI_VERSION_CODE_SUFFIXES } = require("../native-release-version");
|
||||
|
||||
const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-camera",
|
||||
@@ -11,12 +12,15 @@ const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
// Generated from the shared suffix table so the Groovy literal can never drift
|
||||
// from the version codes the F-Droid changelog filenames are derived from.
|
||||
const FDROID_ABI_VERSION_CODE_ENTRIES = Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES)
|
||||
.map(([abi, suffix]) => ` "${abi}": ${suffix},`)
|
||||
.join("\n");
|
||||
|
||||
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
||||
def paseoAbiVersionCodes = [
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
"x86": 3,
|
||||
"x86_64": 4,
|
||||
${FDROID_ABI_VERSION_CODE_ENTRIES}
|
||||
]
|
||||
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
||||
.toString()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
Bug,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Terminal,
|
||||
TestTube,
|
||||
Wrench,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "./profile-appearance";
|
||||
|
||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
||||
const ThemedDefaultIcon = withUnistyles(Star);
|
||||
|
||||
/**
|
||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BottomAnchorRouteRequest {
|
||||
}
|
||||
|
||||
export interface BottomAnchorLocalRequest {
|
||||
reason: "jump-to-bottom" | "message-sent";
|
||||
reason: "jump-to-bottom" | "message-sent" | "rewind";
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,4 +174,151 @@ describe("useChatOutline", () => {
|
||||
|
||||
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("reveals an already-loaded older prompt before scrolling to it", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
const tail = [
|
||||
{
|
||||
id: "older-prompt",
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ visibleItemIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail,
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}),
|
||||
{ initialProps: { visibleItemIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(1));
|
||||
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ visibleItemIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
it("reveals a fetched prompt that lands outside the mounted history window", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const fetch = deferred<void>();
|
||||
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const viewportRef = {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
const fetchedPrompt = {
|
||||
id: "fetched-prompt",
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, visibleItemIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail,
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
visibleItemIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
});
|
||||
|
||||
it("jumps directly to a loaded prompt in the live head", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const scrollToMessage = vi.fn();
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
id: "live-prompt",
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(2),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
};
|
||||
const revealLoadedItem = vi.fn();
|
||||
revealLoadedItem.mockReturnValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail: [],
|
||||
head: [livePrompt],
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds: new Set([livePrompt.id]),
|
||||
revealLoadedItem,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(2));
|
||||
expect(scrollToMessage).toHaveBeenCalledWith("live-prompt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleItemIds?: ReadonlySet<string>;
|
||||
revealLoadedItem?: (itemId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ChatOutline {
|
||||
@@ -51,6 +53,8 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||
@@ -135,7 +139,12 @@ export function useChatOutline({
|
||||
useEffect(() => {
|
||||
if (pendingJump === null) return;
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
if (target && !pendingJump.hasScrolled) {
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
if (visibleItemIds?.has(target.id) === false) {
|
||||
revealLoadedItem?.(target.id);
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(target.id);
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== pendingJump.requestId) return current;
|
||||
@@ -144,7 +153,7 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, viewportRef]);
|
||||
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
@@ -152,6 +161,11 @@ export function useChatOutline({
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedItem?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +185,7 @@ export function useChatOutline({
|
||||
});
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, serverId, viewportRef],
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
|
||||
);
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { layoutStream } from "./layout";
|
||||
import type { StreamStrategy } from "./strategy";
|
||||
|
||||
function strategyFor(platform: "web" | "native"): StreamStrategy {
|
||||
const inverted = platform === "native";
|
||||
const neighborStep = inverted ? 1 : -1;
|
||||
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
|
||||
if (items.length === 0) return null;
|
||||
return inverted ? 0 : items.length - 1;
|
||||
};
|
||||
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
|
||||
if (items.length === 0) return null;
|
||||
return inverted ? items.length - 1 : 0;
|
||||
};
|
||||
return {
|
||||
orderTail: (items) => (inverted ? [...items].toReversed() : items),
|
||||
orderHead: (items) => (inverted ? [...items].toReversed() : items),
|
||||
getNeighborIndex: (index, relation) =>
|
||||
relation === "above" ? index + neighborStep : index - neighborStep,
|
||||
getNeighborItem: (items, index, relation) => {
|
||||
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
|
||||
return items[neighborIndex];
|
||||
},
|
||||
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
|
||||
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
|
||||
getLatestItemIndex: getHistoryBoundaryIndex,
|
||||
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
|
||||
} as StreamStrategy;
|
||||
}
|
||||
|
||||
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-08-22T10:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-08-22T10:00:01.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantBlock(
|
||||
id: string,
|
||||
blockIndex: number,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
...assistantMessage(id),
|
||||
blockGroupId: "response",
|
||||
blockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
return new Map([
|
||||
[
|
||||
assistantId,
|
||||
{
|
||||
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||
durationMs: 2_000,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) {
|
||||
const strategy = strategyFor(input.platform);
|
||||
const assistant = assistantMessage("assistant");
|
||||
return {
|
||||
assistant,
|
||||
layout: layoutStream({
|
||||
strategy,
|
||||
isTurnActive: input.isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||
liveHead: [],
|
||||
timingByAssistantId: timingFor(assistant.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("turn footer spacing", () => {
|
||||
it.each(["web", "native"] as const)(
|
||||
"compacts the last assistant against the completed footer on %s",
|
||||
(platform) => {
|
||||
const { assistant, layout } = layoutFor({ platform, isTurnActive: false });
|
||||
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "native"] as const)(
|
||||
"keeps the same compact edge while the footer is running on %s",
|
||||
(platform) => {
|
||||
const { assistant, layout } = layoutFor({ platform, isTurnActive: true });
|
||||
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["web", false],
|
||||
["web", true],
|
||||
["native", false],
|
||||
["native", true],
|
||||
] as const)(
|
||||
"compacts both edges of the final split paragraph on %s when active=%s",
|
||||
(platform, isTurnActive) => {
|
||||
const strategy = strategyFor(platform);
|
||||
const first = assistantBlock("first", 0);
|
||||
const last = assistantBlock("last", 1);
|
||||
const layout = layoutStream({
|
||||
strategy,
|
||||
isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), first]),
|
||||
liveHead: strategy.orderHead([last]),
|
||||
timingByAssistantId: timingFor(last.id),
|
||||
});
|
||||
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||
|
||||
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
|
||||
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -22,6 +22,22 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
};
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("treats locally revealed history as progress before remote pagination", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
|
||||
expect(first.shouldLoad).toBe(true);
|
||||
expect(localReveal.state).toEqual({
|
||||
status: "settling",
|
||||
loadedProgressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
});
|
||||
it("waits for user scroll intent before loading", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
||||
|
||||
type MountedRecentStreamItemsE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
|
||||
function readPositiveIntegerOverride(value: unknown): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getMountedRecentStreamItems(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as MountedRecentStreamItemsE2ETestGlobals)
|
||||
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
);
|
||||
return override ?? DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
export function findMountedWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
minMountedCount: number;
|
||||
}): number {
|
||||
const { items, minMountedCount } = input;
|
||||
if (items.length <= minMountedCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user