Compare commits

..
672 changed files with 12142 additions and 43835 deletions
-28
View File
@@ -169,34 +169,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
-145
View File
@@ -85,151 +85,6 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
{
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
"rules": {
"paseo/no-sync-io": [
"error",
{
"allow": {
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
"executable-resolution/executable-resolution.ts": ["existsSync"],
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
"executable-resolution/windows.ts": ["readdirSync"],
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
"server/agent/providers/claude/agent.ts": ["existsSync"],
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
"server/agent/providers/claude/subagents/workflow-output.ts": [
"closeSync",
"openSync",
"readSync"
],
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
"server/agent/providers/omp/agent.ts": ["existsSync"],
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
"server/agent/providers/pi/agent.ts": [
"existsSync",
"mkdtempSync",
"readFileSync",
"rmSync",
"writeFileSync"
],
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
"server/config.ts": ["existsSync"],
// Startup identity: load or create the daemon keypair before accepting connections.
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
"server/host-name.ts": ["execFileSync"],
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
"server/hub/relationship-controller.ts": [
"existsSync",
"readFileSync",
"renameSync",
"rmSync"
],
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
"server/local-credential.ts": ["readFileSync"],
// Startup logger construction: create the log directory before opening the logging destination.
"server/logger.ts": ["mkdirSync"],
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
// Module initialization: the package version is resolved once and cached at import.
"server/package-version.ts": ["existsSync", "readFileSync"],
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
"server/persisted-config.ts": ["existsSync", "readFileSync"],
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
"server/pid-lock.ts": ["readFileSync"],
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
"server/plugins/builtin/index.ts": ["existsSync"],
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
// Build child process: select the esbuild binary for the isolated plugin compiler.
"server/plugins/compiler-esbuild.ts": ["existsSync"],
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
"server/private-files.ts": [
"chmodSync",
"mkdirSync",
"realpathSync",
"renameSync",
"rmSync",
"writeFileSync"
],
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
"server/push/token-store.ts": ["existsSync", "readFileSync"],
// Startup identity: load or create the server id before accepting connections.
"server/server-id.ts": ["existsSync", "readFileSync"],
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
// Speech child process: native addon discovery and patchelf run outside the daemon.
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
"existsSync",
"spawnSync"
],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
"server/web-ui.ts": ["readFileSync", "statSync"],
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
"server/workspace-registry-bootstrap.ts": ["statSync"],
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
"terminal/agent-hooks/agent-hook-installer.ts": [
"existsSync",
"readFileSync",
"rmSync"
],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
"terminal/terminal.ts": [
"accessSync",
"chmodSync",
"existsSync",
"mkdirSync",
"readFileSync",
"statSync"
],
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
"utils/path.ts": ["realpathSync"]
}
}
]
}
},
{
"files": [
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"packages/server/src/**/test-utils/**",
"packages/server/src/server/daemon-e2e/**",
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
],
"rules": { "paseo/no-sync-io": "off" }
},
{
"files": [
"plugins/*-provider/server/**/*.ts",
+6 -108
View File
@@ -1,81 +1,6 @@
# Changelog
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
### Added
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### Improved
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
## 0.13.0 - 2026-10-24
## 0.13.0-beta.1 - 2026-10-22
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
@@ -89,26 +14,18 @@ paseo host add 'relay://...' --label laptop
### Added
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### Improved
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -118,44 +35,25 @@ paseo host add 'relay://...' --label laptop
### Plugins
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### Fixed
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+6 -108
View File
@@ -1,81 +1,6 @@
# 更新日志
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。
语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。
### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### 改进
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### 修复
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
## 0.13.0 - 2026-10-24
## 0.13.0-beta.1 - 2026-10-22
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -89,26 +14,18 @@ paseo host add 'relay://...' --label laptop
### 新增
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### 改进
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -118,44 +35,25 @@ paseo host add 'relay://...' --label laptop
### 插件
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### 修复
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+1 -1
View File
@@ -42,7 +42,7 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
## Local daemon trust boundary
+4 -73
View File
@@ -25,54 +25,8 @@ Reload releases the old runtime before resuming its durable session: an idle pro
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
Collection releases the process and preserves the agent record, conversation, relationships, and
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
host config to disable automatic collection. The host setting applies immediately; config-file edits
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
started finishes. Re-enabling keeps the existing idle timestamps.
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
An idle managed child can still own background work.
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
check, the baseline decides collection after the same one-hour idle period. Once protected work
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
Finish subscriptions expose caller protection through their public capability; the collector never
reads their watches or durable records.
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
after closure starts waits for release and resumes the saved session.
Provider checks follow the signals their runtimes expose:
- Claude uses its process-local background roster and pending completion notifications. A fresh
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
demonstrate support by emitting one. Unknown versions stay resident until then.
- Codex queries background terminals for loaded threads, including native children, and protects
pending provider requests. App servers without the terminal-list RPC stay resident.
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
inbox inventories. The shared server remains leased while a session vetoes collection.
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
subagent signals also protect their parent through the daemon's subagent store.
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
runtimes without either field stay resident. These signals are not an inventory of arbitrary
processes.
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
prevent collection. This does not inspect processes launched internally by the provider.
These checks do not discover arbitrary detached processes or work hidden by third-party
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
@@ -125,30 +79,7 @@ Users can also detach an existing subagent from the subagents track. Detach is d
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt
crash can lose queued prompts. A durable send receipt remains pending until actual dispatch
acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued
admission within their original timeout and cancellation scope.
Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module
persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses
`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt
acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch
accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same
caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
retries the captured notification; it does not change the child's reported outcome. Remote host loss
follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining
outstanding intent. A crash after successful dispatch but before removal commits can repeat the
notification. Clean shutdown can also flush an already queued callback while retaining its intent,
so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery.
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
@@ -288,7 +219,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
+4 -19
View File
@@ -44,25 +44,10 @@ connection candidates, including direct connections and routes discovered throug
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
the destination workspace keeps its existing disconnected or reconnecting behavior.
Finish notifications name the destination server and child agent. The caller's daemon owns durable
subscription intent and restores observation through the same routed wait and permission observation
used during ordinary operation. Restarting the caller's daemon does not imply that a remote child
stopped. Three failed remote observation attempts end the subscription with a notification that the
child could not be observed and its status is unknown. This does not prove the host is offline: remote
RPC failures also prevent observation. Attempts use capped backoff; a connection still
in progress does not spend that budget. A confirmed removed registration reports host unavailability
immediately through the same path. Neither notice claims the child finished. Intent remains stored
until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The
parent decides whether to inspect the child or delegate again.
A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt
response retains intent because the destination may have admitted it; observation then follows the
same retry policy. A confirmed missing child reports unavailability.
Remote creation and local subscription persistence are separate commits. A failed local write reports
failure even if the destination already created the child; inspect it before retrying creation.
See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment,
and duplicate-delivery semantics. Concurrent prompts gain no turn attribution.
Finish notifications use the existing idle wait, including a pending initial run or an already idle
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
no additional turn attribution or delivery guarantee.
## Connections and recovery
+41 -20
View File
@@ -221,6 +221,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
}
},
providers: {
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -238,8 +244,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: {
dictation: { enabled, stt: SpeechSttConfig },
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
@@ -347,27 +353,42 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
### Speech configuration
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Each use site owns its provider settings: `features.dictation.stt`,
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
it regardless of provider. `providers.local.modelsDir` remains shared storage.
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
Set these to select OpenAI instead of local speech:
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs
into OpenAI sites that lack their own values, using endpoint settings before shared
settings. A provider-less site becomes local; provider names are lower-cased, and
fields ignored by the selected speech provider are discarded. The next save writes only the
inline shape. A legacy key with no site to receive it is discarded with a warning
that names its path, without logging its value.
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
and report effective env sources in `envOverrides`. A site patch that changes
`provider` replaces the entire object. The same or omitted `provider` merges fields
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
options from the previous variant.
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
---
+5 -9
View File
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -126,8 +126,6 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
---
@@ -150,20 +148,18 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
-53
View File
@@ -273,26 +273,6 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
measurements so profiling overhead does not contaminate them.
For diff layout, two benchmarks share one corpus
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
shared document model with the web and native measurers replaced by counters, so it reports how
many strings each backend would have to shape, which is the cost that survives the move to canvas
or Skia:
```bash
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
```
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
React commit time for cold and warm toggles, then scroll frame times in both modes:
```bash
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
```
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
That report includes input and composer height changes plus React work grouped into composer,
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
@@ -562,39 +542,6 @@ patch every build the consumers use: Metro resolves the `react-native` field of
(`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.
## Dependency overrides and workspace tooling
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
`react-native`, …). npm does not apply those overrides to packages it places under a
workspace's own `node_modules`. When `expo-module-scripts` was declared in
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
where the overrides apply; workspace scripts still find the binary through the root
`node_modules/.bin`.
## Dependency signature audit
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
signature and, for packages published with provenance, fetches
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
for a year. An edge that cached it in the seconds before the registry attached the publish
attestation keeps serving a document with only the provenance bundle, and npm fails with:
```
npm error code EMISSINGSIGNATUREKEY
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
```
The same package's registry signature verified a moment earlier with the same keys, so the
key list is intact. The failure names one package, repeats only for that package, and depends
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile and report the URL to npm so they purge it.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+5 -8
View File
@@ -43,16 +43,13 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
editing sessions, caches, file-link resolution and supported navigation context without moving
their providers. Keep this capability at the workspace boundary; new sheet callers must not
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
forward the same Gorhom modal queue; neither creates another stack. See
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
Close, so the title keeps its width. The sheets present
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
workspace provider an ordinary pane has without a context bridge
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
+18 -34
View File
@@ -115,10 +115,11 @@ quietly relying on:
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** The composer moves with the keyboard through a
`KeyboardTranslateView` transform. A portal'd popover is outside that view, so
it does not move with the composer unless it renders its own
`KeyboardTranslateView` with the same translation.
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
translate/padding styles. The composer and chat content must both read that
provider-owned value. A portal'd popover is outside the composer tree — it
does not get that transform unless you apply it yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
@@ -157,7 +158,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
shift hooks, shift style, translate view, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
@@ -255,27 +256,11 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native
animation-end events because the controller can retain a nonzero value after
the keyboard closes.
Anchor a portal to content inside a `KeyboardTranslateView` with
`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the
translation scope is React context and does not cross it (Gotcha 7). It returns:
- `measureAtRest(element)`: the element's window rect with the keyboard
translation left out, or null once the anchor unmounts. Lay the popover out
from this rect. It assumes the translate view's parent does not move with the
keyboard, which holds for the composer dock.
- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so
the popover moves with the anchor on the same driver, including centered
clearance and disabled translation.
- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's
height with it; never animate height per frame.
Do not measure an anchor with `measureInWindow` and correct it by the keyboard
shift. On iOS the translate view uses the native driver, and `measureInWindow`
includes its transform only when the view happened to re-render since the
keyboard moved. Both results were observed, so any correction double-lifts the
popover or leaves it behind the keyboard. `measureAtRest` measures through
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
settles; the composer's resting position changes as the draft grows.
Measure portal anchors while the dock is at rest. If a popover opens during
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
dock's current layout and transform. Snapshot the shift at measurement time and
apply only the subsequent shift delta to the animated `bottom`. Adding the full
shift moves the portal twice and can place it over the composer controls.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
@@ -288,8 +273,7 @@ not be required to repair the offset.
The generic anchored-overlay rule is:
1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it
moves with the keyboard (Gotcha 3).
1. Measure the anchor with `measureInWindow`.
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
@@ -363,10 +347,10 @@ track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
close. This includes native centered dialogs: separate React Native Modals cannot present a
root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host
and container measurement while forwarding the same modal queue. Compact
[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars;
wide layouts keep local geometry. That export is
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
portal host and container measurement while the sheet still joins the same stack. That export is
what `patches/@gorhom+bottom-sheet+*.patch` adds.
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
@@ -421,8 +405,8 @@ Before you write a new one, ask:
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, anchor it with `useKeyboardAnchor()` and wrap it in a
`KeyboardTranslateView` with the anchor's translation (Gotcha 3).
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
+1 -37
View File
@@ -53,18 +53,7 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
tab, and New workspace. It checks empty-content taps and header taps at the cap
without changing the input height. Each
host also opens command/file autocomplete, the model selector, the attachment
menu, and the forge picker from both keyboard states. Autocomplete opens with
the keyboard closed, before the keyboard opens, and after it settles. Each time
the expected best match must be drawn whole between the header and the composer,
above the IME frame, and tapping it there must produce the exact composer text.
Accessibility reports rows behind the keyboard too, so
`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares
bounds. Android leaves the chat and composer out of the accessibility tree while
suggestions are open, so the composer bounds come from a snapshot with an empty
input in the same keyboard state. The queries are partial names with known best
matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts
`/release` finds this repository's `release-beta` skill for the first Claude model,
and `@app` finds `packages/app`. It then submits a long
menu, and the forge picker from both keyboard states. It then submits a long
fixture from each host and checks native stream displacement twice: after the
response settles, and after returning to the bottom and letting the scrollbar
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
@@ -75,31 +64,6 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
the headless input helper cannot verify the
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
To rerun only the autocomplete, model selector, attachment menu, and forge picker
checks on each host, skipping the chat baseline and the growth, dismissal, and
stream scenarios:
```bash
PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android
```
`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same
autocomplete checks on iOS for chat, a workspace draft tab, and New workspace,
with commands, files, and files under a grown draft. Suggestions open while the
keyboard opens (`fill` taps and types at once), after it settles, with it closed,
and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert
accessibility state and cannot compare bounds. Run it where `agent-device`
reaches the simulator, with the development app connected to the fixture host
and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
`.dev/agent-device-artifacts/composer-keyboard-ios`.
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
under an eight-line draft, selection with the keyboard shown and hidden, and
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
The Android sidebar scroll-retention flow requires a connected host with enough
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
+2 -23
View File
@@ -382,15 +382,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
@@ -439,15 +430,8 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
@@ -514,11 +498,6 @@ Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
the plugin bridge transports that query through the existing provider request/event protocol.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
@@ -591,7 +570,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place:
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
+2 -2
View File
@@ -2,7 +2,7 @@
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it.
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Client capability ownership
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

+10 -25
View File
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
@@ -44,21 +44,7 @@ recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches the latest available turns with a soft target of 40
projected items. Backward pages and prompt-jump windows also include whole turns, including
all currently available rows of an active run. There is no per-turn cap: a large run can
exceed the target. Local history mounting uses the same boundaries so revealing older rows
does not change the collapse state of a turn already displayed.
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
Reading history remains available on older hosts, whose pages can still split turns. Forward
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. Older history remains
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
@@ -163,13 +149,12 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail of complete available turns. The item target is soft;
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap.
@@ -253,11 +238,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Every canonical run owns a visible turn, including heartbeats and agent notifications.
Notifications delivered during a run stay inside it; batched notifications share the run
that starts or accepts them. Received messages retain sender attribution, while lifecycle
notifications remain visible events. Collapse, timing and locally mounted history share
these boundaries.
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
prompts, including schedule injections, remain hidden. One visible response can span several canonical
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
+4140 -2366
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.1",
"version": "0.13.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -131,7 +131,6 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
@@ -155,7 +154,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
}
}
@@ -420,40 +420,3 @@ test.describe("Add Project command-center flow", () => {
}
});
});
test.describe("Compact Add project context", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("canceling Add project preserves the sidebar", async ({ page }) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).not.toBeVisible();
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
});
test("successful Add project reveals the new workspace page", async ({
page,
projectPickerFixture,
}) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName);
await page.keyboard.press("Enter");
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
projectPickerFixture.rememberProjectId(projectId);
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: projectPickerFixture.projectName,
projectPath: projectPickerFixture.projectPath,
});
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
});
});
@@ -18,9 +18,7 @@ import {
expectComposerVisible,
cancelAgent,
composerLocator,
expectQueuedMessageButton,
fillComposerDraft,
holdSendButton,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
@@ -53,7 +51,6 @@ import {
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
userScrollsTimelineToHistoryStart,
scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination";
import {
@@ -987,7 +984,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -1004,10 +1001,8 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
// The complete turn arrives on reload; scrolling mounts its oldest rows
// without requiring another page or an increase in total content height.
await userScrollsTimelineToHistoryStart(page);
await expect(submittedPrompt).toBeVisible();
await expect(submittedPrompt).toHaveCount(0);
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally {
@@ -1295,49 +1290,6 @@ test.describe("Agent message submission", () => {
}
});
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -27,10 +27,6 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
@@ -104,32 +100,6 @@ test.describe("Agent timeline pagination", () => {
}
});
// Hosts paged by items can end a page inside a tool run. The older page extends the group
// the reader is on, which changes the row hosting it; the reader must stay with that group
// rather than land at history start and drain every older page.
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseToolCalls");
const agent = await seedItemPagedToolGroupTimeline();
try {
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
history.releasePage(1);
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
@@ -1,69 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function readFileLinkTooltip(page: Page): Promise<void> {
const link = page.getByRole("link", { name: "the guide", exact: true });
await link.hover();
await expect(page.getByRole("tooltip")).toContainText("guide.md");
}
async function leaveFileLink(page: Page): Promise<void> {
await page.mouse.move(5, 5);
await expect(page.getByRole("tooltip")).toHaveCount(0);
}
async function openLinkedFile(page: Page): Promise<void> {
await page.getByRole("link", { name: "the guide", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
async function returnToAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
await leaveFileLink(page);
}
async function switchToFileTab(page: Page): Promise<void> {
await page.getByRole("button", { name: "guide.md", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "tooltip-5811-",
title: "Tooltip regression",
featureValues: {
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await writeFile(
path.join(agent.cwd, "guide.md"),
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
);
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await readFileLinkTooltip(page);
await leaveFileLink(page);
await readFileLinkTooltip(page);
await openLinkedFile(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await switchToFileTab(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await leaveFileLink(page);
} finally {
await agent.cleanup();
}
});
@@ -1415,14 +1415,12 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
if (settings.codeFontSize !== undefined) {
if (localStorage.getItem(settingsKey)) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
...settings,
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
@@ -2,17 +2,11 @@ import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
expectCommandCenterScrollToStayAt,
expectKeyboardNavigationToResume,
} from "../support/helpers/command-center-scroll";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -143,94 +137,3 @@ test("unscoped command search keeps commands visible and reports file-search fai
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
}
});
test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({
page,
}) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-delayed-scroll-",
title: "Delayed file search scroll",
repo: { files: paseoSizedFiles() },
});
try {
const responses = await holdDirectorySuggestionResponses(page);
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible();
await panel.getByTestId("command-center-input").fill("index");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeVisible();
responses.hold();
await panel.getByTestId("command-center-input").fill("index.");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await responses.release();
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expectCommandCenterScrollToStayAt(panel, offset);
await expectKeyboardNavigationToResume(page, panel, offset);
const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel);
await panel.getByTestId("command-center-input").fill("index.ts");
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await expect
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
.toBeLessThan(nextOffset);
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeInViewport({ ratio: 1 });
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+P");
await expect(panel.getByTestId("command-center-input")).toHaveValue("");
await panel.getByTestId("command-center-input").fill("index");
await expect(
panel.getByRole("button", {
name: "index.ts packages/app/src/features/feature-000",
exact: true,
}),
).toBeInViewport({ ratio: 1 });
} finally {
await seeded.cleanup();
}
});
test.describe("Compact file search context", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("a file result opens a sheet above the left sidebar", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "compact-search-file-",
title: "Compact file lookup",
repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] },
});
try {
await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
const search = await openCommandCenter(page);
await search.getByTestId("command-center-input").fill("lookup.ts");
await search.getByRole("button", { name: "lookup.ts", exact: true }).click();
await expect(page.getByTestId("sheet-tab-body")).toBeVisible();
await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts");
await page
.getByTestId("sheet-tab-header")
.getByRole("button", { name: "Close", exact: true })
.click();
await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible();
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -1,21 +1,7 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import {
seedSearchableCommandCenter,
observeCommandCenterRowRenders,
searchCommandCenter,
warmCommandCenterRows,
expectOnlyCommandCenterRowRendered,
expectCommandCenterAgentWindow,
scrollToOldestCommandCenterAgent,
} from "../support/helpers/command-center-performance";
import { gotoAppShell } from "../support/helpers/app";
import {
expectCommandCenterToRemainAtTheTop,
scrollCommandCenterAwayFromSelection,
expectCommandCenterScrollToStayAt,
expectKeyboardNavigationToResume,
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
@@ -30,15 +16,12 @@ test.use({
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({
page,
}) => {
test("opening the command center keeps its first result fully visible", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-scroll-",
title: "Command Center Scroll 00",
});
let newestWorkspaceId = seeded.workspaceId;
try {
for (let index = 1; index <= 8; index += 1) {
const created = await seeded.client.createWorkspace({
@@ -53,7 +36,6 @@ test("Command Center preserves manual scrolling through live updates and resumes
if (!created.workspace) {
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
}
newestWorkspaceId = created.workspace.id;
}
await gotoAppShell(page);
@@ -66,52 +48,6 @@ test("Command Center preserves manual scrolling through live updates and resumes
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
await expectCommandCenterScrollToStayAt(panel, offset);
await expectKeyboardNavigationToResume(page, panel, offset);
} finally {
await seeded.cleanup();
}
});
test("live search updates render only the changed row and keep agents virtualized", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedSearchableCommandCenter();
try {
await observeCommandCenterRowRenders(page);
await gotoAppShell(page);
const panel = await openCommandCenterWithKeyboard(page);
await searchCommandCenter(panel, "Search agent");
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`agent:${getServerId()}:${seeded.agents[59].id}`,
);
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
await searchCommandCenter(panel, "Search agent");
await warmCommandCenterRows(page);
await scrollToOldestCommandCenterAgent(panel);
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
} finally {
await seeded.cleanup();
}
@@ -9,7 +9,6 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const PRIMARY_HOST_LABEL = "Primary Host";
@@ -21,34 +20,6 @@ const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("agent and workspace results retain their icons and overlay live status", async ({
page,
}) => {
const workspace = await seedRunningMockAgentWorkspace({
repoPrefix: "command-center-status-",
title: "Review checkout retries",
model: "five-minute-stream",
initialPrompt: "Keep working on checkout retries",
});
try {
await openAgentRoute(page, workspace);
await expectAgentTabActive(page, workspace.agentId);
const panel = await openCommandCenter(page);
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
);
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
await workspace.client.cancelAgent(workspace.agentId);
await expect(agent.getByRole("progressbar")).toHaveCount(0);
await expect(row.getByRole("progressbar")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("workspace results show their title, host, and branch and open the workspace", async ({
page,
}) => {
@@ -185,74 +156,3 @@ test.describe("Command center workspaces", () => {
}
});
});
test.describe("Compact sidebar inspection", () => {
test.use({ viewport: { width: 390, height: 844 } });
async function openSidebar(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function expectSidebarPreserved(page: import("@playwright/test").Page) {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
test("canceling Search retains the compact sidebar", async ({ page }) => {
await gotoAppShell(page);
await openSidebar(page);
await openCommandCenter(page);
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
await expectSidebarPreserved(page);
});
test("canceling Import session retains the compact sidebar", async ({ page }) => {
await gotoAppShell(page);
await openSidebar(page);
await page
.getByTestId("sidebar-project-empty-state")
.getByRole("button", { name: "Import session", exact: true })
.click();
await expect(page.getByTestId("import-session-sheet")).toBeVisible();
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByTestId("import-session-sheet")).not.toBeVisible();
await expectSidebarPreserved(page);
});
test("Search reveals the same workspace and a selected root agent", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "compact-search-destination-",
title: "Compact destination",
});
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Compact root agent",
});
await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
await openSidebar(page);
const search = await openCommandCenter(page);
await search.getByTestId("command-center-input").fill("Compact destination");
await search
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
await openSidebar(page);
const agentSearch = await openCommandCenter(page);
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Message agent...", exact: true }),
).toBeVisible();
} finally {
await seeded.cleanup();
}
});
});
@@ -99,16 +99,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectSubagentRowVisible(page, child.id);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
const childSheet = sheet(page, child.title);
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
@@ -1,81 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
async function pressInComposer(page: Page, key: string) {
await composerLocator(page).press(key);
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
});
});
@@ -42,14 +42,6 @@ const test = base.extend<{ agent: MockAgentSession }>({
},
});
// The outer meter owns the accessible usage label; its SVG is decorative.
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
const ring = meter.locator("svg");
await expect(ring).toHaveAttribute("aria-hidden", "true");
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
await expect(ring).not.toHaveAttribute("importantForAccessibility");
}
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
@@ -108,7 +100,6 @@ test.describe("context window meter", () => {
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
await expectDecorativeContextRing(meter);
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
@@ -1,7 +1,6 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
@@ -297,187 +296,8 @@ diffPerfDescribe("Diff canvas performance", () => {
await repo.cleanup().catch(() => undefined);
}
});
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
test.setTimeout(300_000);
const corpus = await createPullRequestCorpusRepository();
const client = await connectSeedClient();
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: corpus.repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create wrap performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await instrumentDiffLatency(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
const openStartedAt = performance.now();
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
const open = {
readyMs: Math.round(performance.now() - openStartedAt),
...(await readDiffDocumentRenders(page)),
};
const toggles: WrapToggleMeasurement[] = [];
for (const name of [
"wrap on (cold)",
"wrap off (cold)",
"wrap on (warm)",
"wrap off (warm)",
]) {
toggles.push(await measureWrapToggle(page, name));
}
const unwrappedScroll = await continuouslyScrollCanvas(page);
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
const wrappedScroll = await continuouslyScrollCanvas(page);
const report = {
scenario: "wrap-toggle",
sourceFiles: corpus.fileCount,
sourceLines: corpus.lineCount,
open,
toggles,
scroll: {
unwrapped: summarizeScroll(unwrappedScroll),
wrapped: summarizeScroll(wrappedScroll),
},
};
await attachPerformanceReport(testInfo, report);
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
const [wrapOn, wrapOff] = toggles;
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
} finally {
await client.close().catch(() => undefined);
await corpus.repo.cleanup().catch(() => undefined);
}
});
});
interface WrapToggleMeasurement {
name: string;
/** Pointer down on the toolbar button to two painted frames after the document resized. */
latencyMs: number;
scrollHeightBefore: number;
scrollHeightAfter: number;
longestTaskMs: number;
longTaskTotalMs: number;
diffDocumentCommits: number;
diffDocumentRenderMs: number;
}
interface LongTask {
startTime: number;
duration: number;
}
interface DiffLatencyScope {
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
__PASEO_RESET_RENDER_PROFILE__?: () => void;
__PASEO_LONG_TASKS__?: LongTask[];
__PASEO_WRAP_TOGGLE_AT__?: number;
}
async function createPullRequestCorpusRepository() {
const files = pullRequestCorpus();
const repo = await createTempGitRepo("diff-wrap-perf-", {
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
});
for (const file of files) {
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
}
return {
repo,
fileCount: files.length,
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
};
}
async function instrumentDiffLatency(page: Page): Promise<void> {
await page.addInitScript(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
scope.__PASEO_LONG_TASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
});
}
async function readDiffDocumentRenders(
page: Page,
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === "DiffDocument",
);
return {
diffDocumentCommits: samples.length,
diffDocumentRenderMs: Math.round(
samples.reduce((total, sample) => total + sample.actualDuration, 0),
),
};
});
}
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
const scroll = page.getByTestId("git-diff-scroll");
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
await page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RESET_RENDER_PROFILE__?.();
scope.__PASEO_LONG_TASKS__ = [];
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
document.addEventListener(
"pointerdown",
() => {
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
},
{ capture: true, once: true },
);
});
await page.getByTestId("changes-toggle-wrap-lines").click();
const measured = await scroll.evaluate(async (element, before) => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const deadline = performance.now() + 240_000;
while (element.scrollHeight === before && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const doneAt = performance.now();
// Long task entries are delivered after the task ends; give the observer a turn.
await new Promise((resolve) => setTimeout(resolve, 50));
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
);
return {
latencyMs: Math.round(doneAt - startedAt),
scrollHeightAfter: element.scrollHeight,
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
};
}, scrollHeightBefore);
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
}
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
const { frameSignatures: _frameSignatures, ...summary } = report;
return summary;
}
async function measureAdditionalPerformanceScenario(
page: Page,
cdp: CDPSession,
@@ -182,7 +182,6 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
await test.step("the selected row imports and opens the hydrated transcript", async () => {
await flow.importSession(scenario.importSessionId);
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await capture(page, testInfo, "08-mobile-agent-after-import.png");
});
@@ -229,143 +228,6 @@ test("captures the desktop import sheet and command-center entry", async ({ page
});
});
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
@@ -1,100 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -1,90 +1,29 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect, type Page } from "../support/fixtures";
import { metroTest as test, expect } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectFoldedTurnAtReadingPosition,
expectOldestLoadedTurnPartial,
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
expectTimelinePromptVisible,
userScrollsTimelineToHistoryStart,
scrollThroughOlderHistoryPages,
} from "../support/helpers/timeline-pagination";
interface PublishedDaemonAgent {
serverId: string;
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
agent: MockAgentWorkspace;
/** Register the host in the browser and apply app settings before the first load. */
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
close(): Promise<void>;
}
async function startPublishedDaemonAgent(options: {
publishedVersion: string;
serverIdPrefix: string;
repoPrefix: string;
title: string;
}): Promise<PublishedDaemonAgent> {
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: options.publishedVersion,
});
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
return {
serverId,
daemon,
agent,
async seedBrowser(page, appSettings) {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences, settings }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
);
},
async close() {
await agent.cleanup();
await daemon.close();
},
};
}
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
@@ -98,84 +37,104 @@ test("loads history across browser reconnection while retaining old-daemon conne
observationSockets.add(socket);
});
});
const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
title: "Published daemon pagination regression",
port: daemon.port,
});
const { agent, serverId } = published;
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
try {
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
await published.seedBrowser(page);
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1);
await test.step("reconnect the browser and reload the old daemon history", async () => {
observationSockets.clear();
await page.reload();
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expect.poll(() => observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await published.close();
}
});
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
// starts mid-turn and only folds once the next page completes it; the reader must stay
// with that turn instead of landing at history start and draining every older page.
test("keeps the reader on the turn that folds when an older item page completes it", async ({
page,
}) => {
test.setTimeout(240_000);
const published = await startPublishedDaemonAgent({
publishedVersion: "0.13.0-beta.2",
serverIdPrefix: "srv_old_fold_",
repoPrefix: "timeline-old-daemon-fold-",
title: "Published daemon folded turn pagination",
});
const { agent, serverId } = published;
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
try {
for (const prompt of prompts) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
}
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
const history = await holdOlderHistoryPages(page, agent, {
daemonPort: published.daemon.port,
});
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, prompts.at(-1)!);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
history.releasePage(1);
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
await history.expectSettledWithRequestedPages(1);
} finally {
await published.close();
await agent.cleanup();
await daemon.close();
}
});
@@ -105,17 +105,6 @@ async function selectFileView(page: Page, view: "Preview" | "Source") {
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") {
const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true });
await button.click();
await page.reload();
if (view === "Source") {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
} else {
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
}
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
@@ -231,9 +220,6 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
await rememberMarkdownViewAcrossReload(page, "Preview");
await rememberMarkdownViewAcrossReload(page, "Source");
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
// cursor and vim mode in the panel's toolbar.
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
@@ -1,5 +1,4 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -7,8 +6,6 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -16,24 +13,13 @@ for (const width of [1100, 390]) {
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page);
});
});
@@ -1,7 +1,5 @@
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -216,66 +214,3 @@ test.describe("provider settings overlay stack", () => {
}
});
});
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
@@ -136,8 +136,7 @@ test.describe("real provider subagent timelines", () => {
panel.getByText("Start chatting with this agent...", { exact: true }),
).toHaveCount(0);
// A provider subagent has no composer; its callout is the way back to the parent.
await page.getByTestId("parent-agent-callout-open").click();
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 });
@@ -1,6 +1,6 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
@@ -34,218 +34,8 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?";
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
function selectedText(page: Page) {
return page.evaluate(() => window.getSelection()?.toString().trim());
}
const CHECKS_QUESTION = "Which checks should run?";
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
async function openLinkedQuestions(page: Page, context: BrowserContext) {
await context.route("https://example.com/**", (route) =>
route.fulfill({ status: 200, body: "ok" }),
);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-option-links-",
title: "Question option links e2e",
initialPrompt: "Emit synthetic questions with links.",
});
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
return { session, card: page.getByTestId("question-form-card").first() };
}
async function dragAcross(page: Page, target: Locator) {
const bounds = await target.boundingBox();
if (!bounds) throw new Error("Text is not visible");
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 1, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
await page.mouse.up();
}
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
const popup = context.waitForEvent("page");
await link.click();
await expect.poll(async () => (await popup).url()).toBe(url);
}
async function expectChecked(option: Locator, checked: boolean) {
await expect(option).toHaveAttribute("aria-checked", String(checked));
}
test.describe("Question option text", () => {
test("links in an option label or description open without answering", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
const done = card.getByRole("radio", { name: "Done" });
const notYet = card.getByRole("radio", { name: "Not yet" });
await clickLinkAndExpectPage(
context,
notYet.getByRole("link", { name: "status" }),
"https://example.com/status",
);
await clickLinkAndExpectPage(
context,
done.getByRole("link", { name: "https://example.com/logs" }),
"https://example.com/logs",
);
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await expectChecked(notYet, false);
} finally {
await session.cleanup();
}
});
test("selecting description text never answers, and the label still does", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
const done = card.getByRole("radio", { name: "Done" });
const description = done.getByTestId("question-form-option-description");
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
await expectChecked(done, false);
await description.getByText("Logs are at", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Logs");
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await done.getByText("Done", { exact: true }).click();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true);
} finally {
await session.cleanup();
}
});
test("double-clicking a single-select label answers once", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
} finally {
await session.cleanup();
}
});
test("multi-select description links and selection leave the option unchanged", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" });
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
const description = lint.getByTestId("question-form-option-description");
await clickLinkAndExpectPage(
context,
description.getByRole("link", { name: "https://example.com/lint" }),
"https://example.com/lint",
);
await expectChecked(lint, false);
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
await expectChecked(lint, false);
await description.getByText("Rules:", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Rules");
await expectChecked(lint, false);
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, true);
} finally {
await session.cleanup();
}
});
});
test.describe("Question prompt pagination", () => {
test("question links open without answering, and the question text is selectable", async ({
page,
context,
}) => {
test.setTimeout(180_000);
await context.route("https://example.com/**", (route) =>
route.fulfill({ status: 200, body: "ok" }),
);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-links-",
title: "Question links e2e",
initialPrompt: "Emit synthetic questions with links.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
const card = page.getByTestId("question-form-card").first();
const question = card.getByTestId("question-form-current-question");
await expect(question).toHaveText(LINKED_QUESTION);
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
const bounds = await question.boundingBox();
if (!bounds) throw new Error("Question text is not visible");
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 1, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
await page.mouse.up();
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
const popup = context.waitForEvent("page");
await question.getByRole("link", { name: "the setup guide" }).click();
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
for (const option of ["Done", "Not yet"]) {
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
"aria-checked",
"false",
);
}
} finally {
await session.cleanup();
}
});
test("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000);
@@ -1,7 +1,3 @@
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -29,8 +25,6 @@ import {
removeHostFromHostPage,
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
setIdleAgentRelease,
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
@@ -143,100 +137,6 @@ test.describe("Settings host page", () => {
}
});
test("idle agent release changes live on one host and stays saved without restarting", async ({
page,
}) => {
test.setTimeout(120_000);
const primaryServerId = getServerId();
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "idle-release-settings",
port: daemon.port,
});
const originalPid = (await client.getDaemonStatus()).pid;
const expectLiveSavedSetting = async (enabled: boolean) => {
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
};
const hosts = [
{
serverId: primaryServerId,
label: TEST_HOST_LABEL,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: daemon.serverId,
label: "Idle release host",
endpoint: `127.0.0.1:${daemon.port}`,
},
];
const openHostAgentsInFreshApp = async (serverId: string) => {
await seedSavedSettingsHosts(page, hosts);
await page.reload();
await openSettings(page);
await selectSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
};
try {
await test.step("idle agent release starts on and turns off", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, true);
await setIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
});
await test.step("a fresh app reads the saved setting from the same running host", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
await setIdleAgentRelease(page, true);
await expectLiveSavedSetting(true);
await setIdleAgentRelease(page, false);
});
await test.step("other hosts keep their own setting", async () => {
await selectSettingsHost(page, primaryServerId);
await openHostSection(page, primaryServerId, "agents");
await expectIdleAgentRelease(page, true);
await expectLiveSavedSetting(false);
});
} finally {
await client.close();
await daemon.close();
}
});
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
const configPath = path.join(daemon.paseoHome, "config.json");
try {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openHostSection(page, daemon.serverId, "agents");
await expectIdleAgentRelease(page, true);
// A real filesystem failure, with the daemon and RPC path intact.
await rename(configPath, `${configPath}.saved`);
await mkdir(configPath);
await page.getByRole("switch", { name: "Release idle agents" }).click();
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
"Failed to read",
);
await expectIdleAgentRelease(page, true);
await rm(configPath, { recursive: true });
await rename(`${configPath}.saved`, configPath);
await setIdleAgentRelease(page, false);
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
} finally {
await daemon.close();
}
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
@@ -6,10 +6,7 @@ import {
addDirectHostFromSettings,
clickSettingsBackToWorkspace,
openSettingsHostSection,
openSettingsSection,
selectSettingsHost,
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
@@ -47,14 +44,6 @@ test.describe("Settings host selection", () => {
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
await selectSettingsHost(page, getServerId());
await openSettingsSection(page, "appearance");
await openSettingsHostSection(page, getServerId(), "connections");
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
} finally {
await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().catch(() => undefined);
@@ -10,15 +10,6 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
closeCompactSettings,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
@@ -52,8 +43,6 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -63,167 +52,7 @@ async function openWorkspace(
await expect(page.getByTestId("menu-button")).toBeVisible();
}
test.describe("Settings sidebar scroll position", () => {
test.use({ viewport: { width: 1000, height: 500 } });
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await scrollSettingsSidebarToEnd(page);
await page.setViewportSize({ width: 1000, height: 1100 });
await expectSettingsSidebarScrollOffset(page, 0);
await page.setViewportSize({ width: 1000, height: 500 });
await openSettingsSection(page, "appearance");
await expectSettingsSidebarScrollOffset(page, 0);
await scrollSettingsSidebarToEnd(page);
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
await openSettingsHostSection(page, getServerId(), "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
const detailOffset = await scrollSettingsDetailDown(page);
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
await scrollSettingsSidebarUp(page, 60);
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
await expectSettingsDetailScrollOffset(page, detailOffset);
});
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const scrollTop = await scrollSettingsSidebarToEnd(page);
await selectSettingsHostSectionsRapidly(page);
await expectSettingsSidebarScrollOffset(page, scrollTop);
await expectSettingsSidebarScrollSettled(page, scrollTop);
});
test("keeps the sidebar position when switching between app and host sections", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
for (const section of [
"providers",
"terminals",
"providers",
"terminals",
"providers",
] as const) {
await openSettingsHostSection(page, getServerId(), section);
await expect(page.getByTestId("page-title")).toHaveText(
section === "providers" ? "Providers" : "Terminals",
);
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
}
const sidebar = page.getByTestId("settings-sidebar");
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
await openSettingsSection(page, "about");
await expectAboutContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
await openSettingsSection(page, "diagnostics");
await expectDiagnosticsContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
});
});
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -248,28 +77,6 @@ test.describe("Settings sidebar navigation", () => {
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("host choice survives app sections and host screen replacements", async ({ page }) => {
const primaryServerId = getServerId();
const secondaryServerId = "srv_e2e_settings_secondary";
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
await seedSavedSettingsHosts(page, [
{ serverId: primaryServerId, label: "First horse", endpoint },
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
]);
await gotoAppShell(page);
await openSettings(page);
await selectSettingsHost(page, secondaryServerId);
await openSettingsHostSection(page, secondaryServerId, "connections");
await openSettingsSection(page, "appearance");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsSection(page, "general");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsHostSection(page, secondaryServerId, "connections");
await selectSettingsHost(page, primaryServerId);
await openSettingsSection(page, "about");
await expectSettingsHostPickerLabel(page, "First horse");
});
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
@@ -340,42 +147,6 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
@@ -452,15 +223,6 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsSection(page, "appearance");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
});
test("removing the last active host returns to welcome after settings closes", async ({
@@ -479,76 +241,3 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
const settings = page
.getByRole("button", { name: "Settings", exact: true })
.filter({ visible: true });
await expect(history).toBeVisible();
await expect(settings).toBeVisible();
return {
width,
history: await history.evaluate((el) => el.getBoundingClientRect().height),
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
};
}
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const sidebar = page.getByTestId("settings-sidebar");
const detail = page.getByTestId("settings-detail-pane");
await expect(sidebar).toBeVisible();
await expect(detail).toBeVisible();
return {
width,
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
};
}
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
const wide = await measureDockedNavigation(page, 951);
for (const width of [600, 669, 719, 720]) {
const narrow = await measureDockedNavigation(page, width);
expect
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.history, 0);
expect
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.settings, 0);
}
}
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
const reference = await measureSettingsSplit(page, 720);
for (const width of [600, 669, 719]) {
const narrow = await measureSettingsSplit(page, width);
expect
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
.toBeGreaterThanOrEqual(reference.detail);
expect
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
.toBeLessThan(reference.sidebar);
}
const wide = await measureSettingsSplit(page, 951);
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
expect(wide.detail).toBeGreaterThan(reference.detail);
}
test("docked navigation keeps its density and Settings preserves reading width", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectConsistentDockedNavigation(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await expectAppearanceContent(page);
await expectSettingsDetailKeepsReadingWidth(page);
await page.setViewportSize({ width: 599, height: 951 });
await expectSettingsSidebarHidden(page);
await expectAppearanceContent(page);
});
@@ -1,4 +1,3 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -11,16 +10,6 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
await page
.getByTestId(`workspace-tab-agent_${agentId}`)
.filter({ visible: true })
.first()
.click();
await expectAgentTabActive(page, agentId);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
interface ComposerFrame {
at: number;
visible: boolean;
@@ -38,9 +27,7 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -191,8 +178,6 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await openComposerTab(page, firstAgent.id);
await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -209,7 +194,6 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
@@ -275,123 +259,3 @@ test.describe("Settings toggle tab regression", () => {
}
});
});
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
@@ -6,7 +6,6 @@ import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
expectCompactSidebarOpen,
} from "../support/helpers/usage-sidebar-item";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
@@ -91,13 +90,12 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await expectCompactSidebarOpen(page);
await page.setViewportSize({ width: 1440, height: 900 });
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectCompactSidebarOpen(page);
await openCompactSidebar(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-with-rows");
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
@@ -362,60 +362,3 @@ test("compact groups retain active overflow and reveal the current ordered rows"
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
await seed.cleanup();
}
});
@@ -72,9 +72,6 @@ async function expectSortingRequiresHostUpdate(page: Page) {
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
@@ -180,7 +180,6 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) {
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, workspace);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await openMobileAgentSidebar(page);
}
@@ -125,29 +125,6 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
}
});
test("a subagent's parent pill returns to its parent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Refactor auth flow",
childTitle: "Write session tests",
});
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
const pill = page.getByTestId("composer-parent-agent-pill");
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
await pill.click();
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
"aria-selected",
"true",
);
} finally {
await workspace.cleanup();
}
});
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
}
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -1,558 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
},
},
},
});
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
async function seedAnsweredAgent(title: string) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-collapse-",
title,
model: "ten-second-stream",
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(
agent.agentId,
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
);
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
/** Chat rows between the latest turn's summary line and its reply, in document order. */
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const replyRow = rows.findLastIndex((row) =>
row.querySelector("[data-testid=assistant-message]"),
);
return replyRow - summaryRow - 1;
});
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
return rows
.slice(start + 1, end)
.flatMap((row) =>
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
(group) => group.textContent ?? "",
),
);
});
}
async function expectCompactMessageToggle(prompt: Locator, label: string) {
const button = prompt.getByRole("button", { name: label, exact: true });
const text = prompt.getByText(label, { exact: true });
await expect
.poll(
async () => {
const control = (await button.boundingBox())!;
const glyphs = (await text.boundingBox())!;
return control.height - glyphs.height;
},
{ message: "The inline toggle adds no vertical space beyond its text" },
)
.toBe(0);
}
async function expandAndCollapseLongPrompt(page: Page) {
const prompt = page.getByTestId("user-message").last();
const before = await prompt.boundingBox();
const text = prompt.locator('[data-message-text="true"]');
const messageLeft = (await text.boundingBox())!.x;
await expect
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expectCompactMessageToggle(prompt, "Show more");
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
await expect
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expect
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
.toBeGreaterThan(before!.height);
await expectCompactMessageToggle(prompt, "Show less");
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
await prompt.scrollIntoViewIfNeeded();
const bounds = (await prompt.boundingBox())!;
const body = (await text.boundingBox())!;
await page.screenshot({
path: "/tmp/message-toggle-aligned.png",
clip: {
x: body.x - 16,
y: bounds.y,
width: bounds.x + bounds.width - body.x + 16,
height: bounds.height,
},
});
}
test.describe("Turn collapse", () => {
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expandAndCollapseLongPrompt(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^Edited 1 file/);
// Text chunks that stream back to back merge into one message, so the reply holds
// at least the stream's last chunk.
await expect(page.getByTestId("assistant-message").last()).toHaveText(
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
});
test("received messages show their sender and open that agent", async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "sender-attribution-",
title: "Implementation",
});
try {
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
await expect(attribution).toBeVisible();
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
"I reviewed the turn-grouping changes.",
);
const labelColor = await attribution
.getByText("Sent by Reviewer", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
} finally {
await recipient.cleanup();
}
});
interface MessagePreviewWatch {
/** Frames after the watch started on which the fade or "Show more" control was missing. */
framesWithoutClip: number[];
/** Frames on which the fade was a different DOM node than when the watch started. */
framesRemounted: number[];
}
declare global {
interface Window {
__paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch };
}
}
async function seedReceivedReviewWithToolTurn() {
const recipient = await seedMockAgentWorkspace({
repoPrefix: "preview-clip-",
title: "Implementation",
});
const sender = await recipient.client.createAgent({
provider: "mock",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
modeId: "load-test",
model: "e2e-fast-stream",
});
// Long enough to clip behind the preview fade; the instruction makes the recipient run tools.
await recipient.client.sendAgentMessage(
recipient.agentId,
"Review complete: the regression is red on the baseline and green with the fix.\n" +
"Keep the previous response visible while the reader scrolls.\n".repeat(30) +
"Now emit 7 tool agent stream updates",
{ sourceAgentId: sender.id },
);
return recipient;
}
/** Samples the received message's bubble on every animation frame until stopped. */
async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise<void> {
await page.evaluate((label) => {
const findBubble = () =>
[...document.querySelectorAll("[data-testid=user-message]")].find((row) =>
row.textContent?.includes(label),
) ?? null;
const initialFade = findBubble()?.querySelector("svg") ?? null;
if (!initialFade) throw new Error(`No faded bubble for ${label}`);
const framesWithoutClip: number[] = [];
const framesRemounted: number[] = [];
let frame = 0;
let running = true;
const sample = () => {
if (!running) return;
const bubble = findBubble();
const fade = bubble?.querySelector("svg") ?? null;
if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame);
else if (fade !== initialFade) framesRemounted.push(frame);
frame += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
window.__paseoMessagePreviewWatch = {
stop() {
running = false;
return { framesWithoutClip, framesRemounted };
},
};
}, senderLabel);
}
async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise<void> {
// Give the opened turn's layout a few frames to settle before judging.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop());
expect(
watch.framesWithoutClip,
"the preview fade and Show more control stay on every frame",
).toEqual([]);
expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]);
}
// Opening a turn must not rebuild its host row: the preview above the summary loses its
// measured height when remounted, which drops the fade and "Show more" for a frame.
test("opening a turn keeps the received message above it clipped on every frame", async ({
page,
}) => {
await useChatDisplay(page, "collapseTurns");
const recipient = await seedReceivedReviewWithToolTurn();
try {
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({
timeout: 30_000,
});
const bubble = page
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 7 commands", exact: true })
.click();
await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible();
await expectReceivedMessagePreviewUndisturbed(page);
} finally {
await recipient.cleanup();
}
});
async function expectSenderAlignedWithBubble(attribution: Locator) {
const edges = await attribution.evaluate((button) => {
const bubble = button.nextElementSibling;
const label = button.querySelector('[dir="auto"]');
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
return {
labelRight: label.getBoundingClientRect().right,
bubbleRight: bubble.getBoundingClientRect().right,
};
});
expect(
Math.abs(edges.labelRight - edges.bubbleRight),
"sender and bubble share the right edge",
).toBeLessThanOrEqual(1);
}
async function seedWholeTurnHistory() {
const agent = await seedMockAgentWorkspace({
repoPrefix: "whole-turn-history-",
title: "Whole turns",
model: "e2e-fast-stream",
});
for (const prompt of [
...Array.from(
{ length: 25 },
(_, index) => `Short ${index}: emit 1 coalesced agent stream updates`,
),
"Older long turn: emit 60 tool agent stream updates",
"Latest long turn: emit 120 tool agent stream updates",
]) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return agent;
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const latest = page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await expect(
page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
async function useThinkingDisplay(
page: Page,
autoExpandReasoning: boolean,
chatDisplay: ChatDisplay,
) {
await page.addInitScript(
(settings) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({
...settings,
chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay,
}),
);
},
{ autoExpandReasoning, chatDisplay },
);
}
for (const autoExpandReasoning of [false, true]) {
test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls");
const agent = await seedMockAgentWorkspace({
repoPrefix: "thinking-group-",
title: "Thinking with tools",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
await expectThinkingPresentation(page, autoExpandReasoning);
await switchToCollapsedTurns(page);
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toBeVisible();
await expect(summary).not.toContainText(/thought/i);
await expect(reasoning).toHaveCount(0);
await summary.click();
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await openOneToolOutput(page);
await summary.click();
await expect(reasoning).toHaveCount(0);
} finally {
await agent.cleanup();
}
});
}
async function openOneToolOutput(page: Page) {
const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true });
await expect(output).toHaveCount(0);
const command = page
.getByTestId("tool-call-badge")
.filter({ hasText: "node scripts/simulate-stream-burst.mjs" })
.first();
await command.getByRole("button").first().click();
await expect(output.first()).toBeVisible();
await command.getByRole("button").first().click();
await expect(output).toHaveCount(0);
}
async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) {
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
if (autoExpandReasoning) {
await expect(reasoning).toBeVisible();
await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0);
} else {
await expect(reasoning).toHaveCount(0);
const group = page
.getByTestId("tool-call-group")
.filter({ hasText: /read 1 file/i, visible: true })
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await group.getByRole("button").first().click();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
const thought = group
.getByTestId("tool-call-badge")
.filter({ hasText: /^Thinking/ })
.first();
await expect(thought).toBeVisible();
await expect(reasoning).toHaveCount(0);
// A press right after the group opens occasionally does not register; retry until the
// thought row opens.
await expect(async () => {
await thought.getByRole("button").first().click();
await expect(reasoning).toBeVisible({ timeout: 2_000 });
}).toPass();
}
}
async function switchToCollapsedTurns(page: Page) {
await page.evaluate(() => {
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
});
await page.reload();
}
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
page,
}) => {
test.setTimeout(90_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "heartbeat-turn-",
title: "Heartbeat turns",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
const schedules = agent.client as unknown as Pick<
DaemonClient,
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
>;
let scheduleId: string | undefined;
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summaries = page.getByTestId("turn-summary");
await expect(summaries).toHaveCount(1);
const previousSummary = await summaries.first().innerText();
const previousReplyId = await page
.getByTestId("assistant-message")
.last()
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
const created = await schedules.scheduleCreate({
name: "Check the completed work",
prompt: "Inspect the scroll container again",
cadence: { type: "cron", expression: "0 0 1 1 *" },
target: { type: "self", agentId: agent.agentId },
runOnCreate: false,
});
expect(created.error).toBeNull();
scheduleId = created.schedule!.id;
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
expect(fired.error).toBeNull();
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
).toBeVisible();
await expect(summaries.first()).toHaveText(previousSummary);
const previousReply = page
.locator(`[data-message-id="${previousReplyId}"]`)
.getByTestId("assistant-message");
await expect(previousReply).toBeVisible();
await expect(previousReply).toContainText("(end of synthetic stream)");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(summaries).toHaveCount(2);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
} finally {
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
await agent.cleanup();
}
});
@@ -1,93 +0,0 @@
import { expect, test } from "../support/fixtures";
import {
apiKeyInput,
baseUrlInput,
expectSheetClosed,
expectSpeechSiteSummary,
openSpeechSite,
openVoiceSettings,
providerOption,
readSpeechSite,
resetSpeechSite,
saveSpeechSite,
withUnwritableDaemonConfig,
} from "../support/helpers/voice-settings";
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
// download. That makes the provider picker read-only here; the env key covers the key fallback.
const ENV_API_KEY = "sk-e2e-env-fallback";
const SAVED_API_KEY = "sk-e2e-saved-key";
const BASE_URL = "http://127.0.0.1:9/v1";
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
page,
}) => {
try {
await openVoiceSettings(page);
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
const sheet = await test.step("the env var decides the provider", async () => {
const opened = await openSpeechSite(page, "Listening");
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
await expect(providerOption(opened, "Local")).toBeDisabled();
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
return opened;
});
await test.step("a saved key and base URL win over the env key", async () => {
await apiKeyInput(sheet).fill(SAVED_API_KEY);
await baseUrlInput(sheet).fill(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.stt"))
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
});
await test.step("reopening shows the key as saved without revealing it", async () => {
const reopened = await openSpeechSite(page, "Listening");
await expect(apiKeyInput(reopened)).toHaveValue("");
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
});
await test.step("clearing the base URL keeps the saved key", async () => {
const sheetAgain = page.getByRole("dialog");
await baseUrlInput(sheetAgain).fill("");
await saveSpeechSite(sheetAgain);
await expectSheetClosed(page);
const site = await readSpeechSite("voiceMode.stt");
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
expect(site).not.toHaveProperty("baseUrl");
});
} finally {
await resetSpeechSite("voiceMode.stt");
}
});
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
try {
await openVoiceSettings(page);
const sheet = await openSpeechSite(page, "Speaking");
await baseUrlInput(sheet).fill(BASE_URL);
await withUnwritableDaemonConfig(async () => {
await saveSpeechSite(sheet);
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
});
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.tts"))
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
} finally {
await resetSpeechSite("voiceMode.tts");
}
});
@@ -11,10 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -108,83 +105,6 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
@@ -6,7 +6,6 @@ for await (const line of lines) {
const command = JSON.parse(line);
let data = {};
if (command.type === "get_available_models") {
if (process.argv.includes("--stall-model-discovery")) continue;
data = {
models: [
{
@@ -19,13 +19,6 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')"
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
# `overlays` runs only each host's autocomplete, model selector, attachment menu, and
# forge picker checks; `all` also runs the chat baseline and the full host scenario.
SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}"
if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then
echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2
exit 1
fi
if [[ -z "${SERVER_ID}" ]]; then
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
@@ -180,10 +173,8 @@ sleep 3
# Create the fixture while the client is connected so its directory replica observes the insert.
ad wait 'id="message-input-root"' 90000
# The compact header names the workspace, not the agent, so the chat waits below find
# the fixture by its prompt bubble.
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
npm run --silent cli -- run "${AGENT_TITLE}" \
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
--background \
--title "${AGENT_TITLE}" \
--provider mock \
@@ -215,241 +206,238 @@ clear_input
ad keyboard dismiss || true
ad wait 'label="Message, @files, /commands"' 10000
# Chat-only invariants, then the long-draft checks on New workspace.
run_chat_baseline() {
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
capture_screen "${ARTIFACTS_DIR}/baseline.png"
read -r input_x input_y baseline_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
)
read -r model_x model_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
)
read -r attach_x attach_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
)
read -r _ header_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
)
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
keyboard_shift="$(read_keyboard_shift)"
adb shell input keyevent 76
sleep 1
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
model_open_y="$((model_y - keyboard_shift))"
adb shell input tap "${model_x}" "${model_open_y}"
sleep 1
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="compact-provider-list"' 10000
ad press 'label="Close"' --settle
adb shell input tap "${input_x}" "${input_y}"
clear_focused_input_with_adb
ad wait 'editable=true' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
clear_focused_input_with_adb
adb shell input keyevent 76
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="composer-autocomplete-popover"' 10000
ad wait 'text="/exit"' 10000
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
node "${ASSERT}" above-y \
"${ARTIFACTS_DIR}/command-popover.json" \
"/exit, Archive the current agent" \
"$((input_y - baseline_input_height / 2))"
adb shell input keyevent 67
ad wait 'label="Message, @files, /commands"' 10000
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/multiline-first.json" \
"${ARTIFACTS_DIR}/multiline-second.json"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent HOME
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
sleep 1
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
keyboard_shift="$(read_keyboard_shift)"
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
node "${ASSERT}" same-header \
"${ARTIFACTS_DIR}/baseline.png" \
"${ARTIFACTS_DIR}/keyboard-open.png" \
"$((header_y + 48))"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
# so the editing input renders again after every edit
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
# row on the paseo-api35 layout.
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
open_gboard "${input_x}" "${input_y}"
adb shell input keycombination 113 122
adb shell input keyevent 20 20 20 20 20 20 20 20
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
backspace_x="$((screen_width - 85))"
backspace_y="$(($(read_ime_top) + 507))"
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
adb shell input keycombination 113 123
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'label="Message, @files, /commands"' 10000
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-hold-delete.json"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
read -r changes_x changes_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
)
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
ad wait 'id="changes-header"' 10000
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
ad press 'label="Close Explorer sidebar"' --settle
ad wait "text=\"${AGENT_TITLE}\"" 10000
ad wait 'editable=true' 10000
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
sleep 1
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait 'id="message-input-attachment-menu-content"' 10000
ad press 540 600 --settle
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
keyboard_shift="$(read_keyboard_shift)"
adb shell input tap 983 "$((attach_y - keyboard_shift))"
wait_for_ime false
adb shell ime set "${HELPER_IME}" >/dev/null
ad wait text "keyboard invariant line one" 10000
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/baseline.json" \
"${ARTIFACTS_DIR}/after-submit.json"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
open_gboard "${input_x}" "${input_y}"
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
stall_pid="$!"
sleep 0.25
adb shell input keyevent BACK
wait_for_ime false
sleep 0.5
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
wait "${stall_pid}"
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
1400 \
430
node "${ASSERT}" same-region \
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
1400 \
430
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "x" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
read -r new_input_x new_input_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
)
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
# Typing more lines moves the setup fields up with the composer instead of
# hiding their bottom rows behind it.
adb shell input keycombination 113 123
adb shell input keyevent 66 66 66
adb shell input text grown
sleep 1
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
node "${ASSERT}" xml-fields-follow-growth \
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "x" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
read -r new_input_x new_input_y _ < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
)
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
# Typing more lines moves the setup fields up with the composer instead of
# hiding their bottom rows behind it.
adb shell input keycombination 113 123
adb shell input keyevent 66 66 66
adb shell input text grown
sleep 1
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
node "${ASSERT}" xml-fields-follow-growth \
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
# Dismissing the keyboard moves the composer down at its editing height and
# brings the setup fields back fully visible above it.
adb shell input keyevent BACK
wait_for_ime false
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
node "${ASSERT}" xml-fields-above-composer \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
}
# Dismissing the keyboard moves the composer down at its editing height and
# brings the setup fields back fully visible above it.
adb shell input keyevent BACK
wait_for_ime false
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
node "${ASSERT}" same-input-height \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
node "${ASSERT}" xml-fields-above-composer \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
# All hosts run the same scenario; only navigation differs.
# For the shared scenario, tap the editor's blank right edge once. Tapping
@@ -482,80 +470,20 @@ press_composer_control() {
adb shell input tap "${x}" "${y}"
}
# Opens suggestions with the keyboard closed, before it opens, or after it settled;
# checks their bounds and taps the best match where it is drawn. Accessibility keeps
# reporting rows behind the keyboard, so existence is not evidence. It also drops the
# composer while suggestions are open, so the composer bounds come from a reference
# snapshot with an empty input in the keyboard state the suggestions are checked in.
check_autocomplete() {
local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6"
local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}"
if [[ "${order}" == keyboard-closed ]]; then
prepare_composer closed
snapshot_json "${prefix}-reference.json"
adb shell input text "${query}"
else
prepare_composer open
# The composer follows the keyboard animation, which ends after the IME frame settles.
sleep 1
snapshot_json "${prefix}-reference.json"
if [[ "${order}" == before-keyboard ]]; then
adb shell ime set "${HELPER_IME}" >/dev/null
wait_for_ime false
adb shell input text "${query}"
open_editor_keyboard "${overlay_x}" "${overlay_y}"
else
adb shell input text "${query}"
fi
fi
ad wait 'id="composer-autocomplete-popover"' 10000
# The popover remeasures once keyboard motion settles.
sleep 1
capture_screen "${prefix}.png"
snapshot_json "${prefix}.json"
local ime_top=""
if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi
local best_x best_y
read -r best_x best_y < <(
node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}"
)
adb shell input tap "${best_x}" "${best_y}"
sleep 1
snapshot_json "${prefix}-selected.json"
node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}"
clear_focused_input_with_adb
echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted"
}
# Drafts list commands for their selected provider, and a fresh install selects none.
select_claude_model() {
local x y h
press_composer_control combined-model-selector
ad wait 'id="agent-controls-model"' 10000
ad press 'id="agent-controls-model"' --settle
ad press 'id="model-provider-claude"' --settle
snapshot_json "${ARTIFACTS_DIR}/model-list.json"
read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*')
adb shell input tap "${x}" "${y}"
ad wait 'id="agent-controls-thinking"' 10000
ad press 'label="Close"' --settle
}
run_host_overlays() {
local host="$1"
if [[ "${host}" != chat ]]; then
prepare_composer closed
select_claude_model
fi
# Chat lists the mock provider's command; drafts list the release skills in
# this repository's .claude/skills for the selected Claude model.
local command_query=/release command_match=/release-beta command_inserted="/release-beta "
if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi
for order in keyboard-closed before-keyboard after-keyboard; do
check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}"
check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"'
done
for mode in closed open; do
for query in / @README; do
prepare_composer closed
adb shell input text "${query}"
if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
ad wait 'id="composer-autocomplete-popover"' 10000
local kind=commands
if [[ "${query}" == @* ]]; then kind=files; fi
capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png"
clear_focused_input_with_adb
echo "PASS host=${host}: ${kind} popover keyboard=${mode}"
done
prepare_composer "${mode}"
press_composer_control combined-model-selector
ad wait 'id="compact-provider-list"' 10000
@@ -728,29 +656,19 @@ run_host_scenario() {
run_host_scroll "${host}"
}
run_host() {
if [[ "${SCENARIO}" == overlays ]]; then
run_host_overlays "$1"
else
run_host_scenario "$1"
fi
}
if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi
adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host chat
run_host_scenario chat
ad keyboard dismiss || true
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
# Only a draft offers to import a session.
ad wait 'id="composer-import-agent-pill"' 10000
run_host workspace-draft
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
run_host new-workspace
run_host_scenario new-workspace
echo "Composer keyboard invariants passed"
@@ -1,7 +1,6 @@
import fs from "node:fs/promises";
import process from "node:process";
import sharp from "sharp";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const [, , command, ...args] = process.argv;
@@ -116,14 +115,11 @@ if (command === "stream-moved") {
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
);
} else if (command === "rect") {
// A trailing `*` matches the first identifier with that prefix.
const [snapshotPath, identifier, edge] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null;
const node = snapshot.data.nodes.find(
(candidate) =>
candidate.identifier === identifier ||
(prefix !== null && candidate.identifier?.startsWith(prefix)) ||
candidate.label === identifier ||
(identifier === "editable" && candidate.type.endsWith("EditText")),
);
@@ -236,27 +232,6 @@ if (command === "stream-moved") {
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
}
} else if (command === "autocomplete-best") {
// Prints the center of the expected best match after checking its placement
// against the composer in the reference snapshot.
const [snapshotPath, referencePath, expected, imeTopArgument] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const reference = JSON.parse(await fs.readFile(referencePath, "utf8"));
const best = resolveAutocompletePlacement(snapshot, {
expected,
keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined,
reference,
});
process.stdout.write(`${best.x} ${best.y}\n`);
} else if (command === "composer-text") {
// Accessibility drops a trailing space, so compare without it.
const [snapshotPath, expected] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
const text = input?.value ?? input?.label ?? "";
if (text.trimEnd() !== expected.trimEnd()) {
throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`);
}
} else if (command === "same-region") {
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
const crop = {
@@ -1,101 +0,0 @@
// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device
// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that
// sit behind the keyboard, so existence is not evidence: compare their screen bounds.
const bottomOf = (rect) => rect.y + rect.height;
function findById(nodes, identifier) {
const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect);
if (!node) throw new Error(`Missing node: ${identifier}`);
return node;
}
function contains(outer, inner) {
return (
inner.x >= outer.x - 1 &&
inner.y >= outer.y - 1 &&
inner.x + inner.width <= outer.x + outer.width + 1 &&
bottomOf(inner) <= bottomOf(outer) + 1
);
}
/**
* The best match is the list row nearest the composer. Check that the popover sits
* between the app's header controls and the composer, that the keyboard (when shown)
* is below both, and that the row nearest the composer is fully drawn and is
* `expected`. A clipped best match fails: the nearest whole row is then another one.
* Return where to tap it. Android leaves the composer out of the accessibility tree
* while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input.
*/
export function resolveAutocompletePlacement(
snapshot,
{ expected, keyboardTop, reference, anchorGap },
) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect;
const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect;
// The popover may cover the compact header, never the status bar above it.
const minimumTop =
nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0;
// iOS reports the keyboard in the app's tree; Android callers pass the IME frame.
const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover);
const gap = composer.y - popoverBottom;
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
}
if (popoverBottom > composer.y + 1) {
throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
);
}
if (popover.y < minimumTop - 1) {
throw new Error(
`Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`,
);
}
if (keyboardY !== undefined && popoverBottom > keyboardY + 1) {
throw new Error(
`Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`,
);
}
// Rows are the innermost labelled boxes spanning the popover. The list scrolls to
// its end, so the lowest row is the best match nearest the composer.
const candidates = nodes.filter(
(node) =>
node.rect &&
node.label &&
node.identifier !== "composer-autocomplete-popover" &&
!/scroll bar/i.test(node.label) &&
node.rect.height > 0 &&
// The popover border insets rows by a few pixels; their labels are narrower.
node.rect.width >= popover.width * 0.97 &&
contains(popover, node.rect),
);
const area = (rect) => rect.width * rect.height;
const rows = candidates.filter(
(outer) =>
!candidates.some(
(inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect),
),
);
if (rows.length === 0) throw new Error("Autocomplete has no visible rows");
const best = rows.reduce((lowest, row) =>
bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest,
);
const label = best.label.split(", ")[0];
if (label !== expected) {
throw new Error(`Best visible match is ${label}, expected ${expected}`);
}
return {
label,
x: Math.round(best.rect.x + best.rect.width / 2),
y: Math.round(best.rect.y + best.rect.height / 2),
popover,
composer,
keyboardTop: keyboardY ?? null,
};
}
@@ -1,340 +0,0 @@
// Composer autocomplete placement on iOS with the software keyboard: chat, workspace
// draft tab, and New workspace. Run where agent-device reaches the simulator, with the
// development app connected to the fixture host. See docs/mobile-testing.md.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback;
const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID");
const scenario = env("SCENARIO", "all");
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
);
}
const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro");
const udid = env("UDID");
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
const artifactsDir = env(
"ARTIFACTS_DIR",
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
);
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match.
const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' };
// The release skills ship in this repository's .claude/skills.
const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " };
// The chat fixture's mock provider lists one command.
const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
}
function snapshot(name) {
const output = ad("snapshot", "--raw", "--json");
fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output);
ad("screenshot", path.join(artifactsDir, `${name}.png`));
return JSON.parse(output);
}
const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect);
const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard");
const composerText = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? "";
async function waitFor(name, predicate, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const snap = snapshot(name);
if (predicate(snap)) return snap;
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`);
await sleep(500);
}
}
// Keyboard motion has settled once two snapshots agree on the composer position.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
}
function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height,
)} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`,
);
return placement;
}
// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a
// draft has no stream, so open and close the model sheet, which takes input focus.
async function dismissKeyboard(snap) {
const composer = nodeById(snap, "message-input-root").rect;
ad("press", "4", String(Math.round(composer.y - 8)));
await sleep(1_000);
if (!keyboardShown(snapshot("dismiss-keyboard"))) return;
const sheetOpen = (sheet) =>
sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close");
ad("press", 'id="combined-model-selector"');
await waitFor("dismiss-keyboard-sheet", sheetOpen);
ad("press", 'label="Close"');
// The input is not hittable until the sheet has gone.
await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done));
}
// Tap past the end of the (short) last line so the caret lands after the mention.
// The input's right padding holds the fullscreen toggle; stay clear of it.
function focusAtEnd(snap) {
const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect;
if (!input) throw new Error("Missing composer input");
ad(
"press",
String(Math.round(input.x + input.width * 0.7)),
String(Math.round(input.y + input.height - 6)),
);
}
// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end.
async function clearInput(name) {
focusAtEnd(snapshot(`${name}-before-clear`));
const keyboard = await waitFor(`${name}-clear`, keyboardShown);
const deleteKey = keyboard.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete" && node.rect,
)?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length;
for (let remaining = length + 2; remaining > 0; remaining -= 100) {
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(Math.min(remaining, 100)),
"--interval-ms",
"40",
);
}
}
// The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function selectRow(name, row, inserted) {
ad("press", String(row.x), String(row.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
);
}
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
const name = `${host}-${kind}`;
await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`));
// fill taps the input and types at once, so suggestions open while the keyboard opens.
ad("fill", "editable=true", text);
assertPlacement(
`${name} opened while keyboard opens`,
await settledPlacement(`${name}-opening`, true),
expected,
);
await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text);
const opened = assertPlacement(
`${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true),
expected,
);
if (selectionKeyboard === "shown") {
await selectRow(name, opened, inserted);
return;
}
await dismissKeyboard(snapshot(`${name}-before-close`));
const closed = assertPlacement(
`${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false),
expected,
);
if (selectionKeyboard === "hidden") {
await selectRow(name, closed, inserted);
return;
}
focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true),
expected,
);
await selectRow(name, reopened, inserted);
}
// Drafts list commands for their selected provider, and a fresh install selects none.
async function selectClaudeModel(host) {
ad("press", 'id="combined-model-selector"');
await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model")));
ad("press", 'id="agent-controls-model"');
await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude")));
ad("press", 'id="model-provider-claude"');
const models = await waitFor(`${host}-models`, (snap) =>
snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect),
);
const row = models.data.nodes.find(
(node) => node.identifier?.startsWith("model-row-claude-") && node.rect,
).rect;
ad(
"press",
String(Math.round(row.x + row.width / 2)),
String(Math.round(row.y + row.height / 2)),
);
await waitFor(`${host}-model-selected`, (snap) =>
Boolean(nodeById(snap, "agent-controls-thinking")),
);
ad("press", 'label="Close"');
await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model"));
}
async function runHost(host) {
if (host !== "chat") await selectClaudeModel(host);
await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase);
await runCase(host, "files", fileCase);
await runCase(host, "files-grown", {
text: `${grownPrefix}${fileCase.text}`,
expected: fileCase.expected,
inserted: `${grownPrefix}${fileCase.inserted}`,
});
await clearInput(`${host}-done`);
await dismissKeyboard(snapshot(`${host}-done`));
}
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
async function runLiveGrowth() {
await clearInput("live-growth");
ad("type", "Please do the following now /paseo");
const before = await settledPlacement("live-growth-before", true);
assertPlacement("before growth", before, "/paseo");
const inputHeight = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
ad("type", "--", "-committee");
const grown = await settledPlacement("live-growth-after", true);
if (inputHeight(grown) <= inputHeight(before))
throw new Error("Draft did not wrap onto a new line");
assertPlacement("live growth without reopening", grown, "/paseo-committee");
const deleteKey = grown.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete",
).rect;
ad(
"press",
String(deleteKey.x + deleteKey.width / 2),
String(deleteKey.y + deleteKey.height / 2),
"--count",
"10",
"--interval-ms",
"40",
);
const shrunk = await settledPlacement("live-growth-shrunk", true);
if (inputHeight(shrunk) !== inputHeight(before))
throw new Error("Draft did not shrink back to one line");
assertPlacement("live shrink without reopening", shrunk, "/paseo");
ad("type", "--", "-committ");
const partial = await settledPlacement("live-growth-partial", true);
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
throw new Error("Could not prepare a partial command for selection");
}
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
ad("press", String(row.x), String(row.y));
await waitFor("live-growth-selected", (snap) =>
sameText(composerText(snap), "Please do the following now /paseo-committee "),
);
await clearInput("live-growth-done");
}
async function runBoundedPreview() {
const prefix =
"Please do the following / testing inline selection.\n" +
"Line two with details.\nLine three with details.\nLine four with details.\n" +
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
await runCase("preview", "long", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "shown",
});
await runCase("preview", "long-hidden", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "hidden",
});
await runCase("preview", "longest", {
text: `${prefix}/paseo-plug`,
expected: "/paseo-plugin",
inserted: `${prefix}/paseo-plugin `,
selectionKeyboard: "shown",
});
await runCase("preview", "short", {
text: `${prefix}/com`,
expected: "/commit",
inserted: `${prefix}/commit `,
selectionKeyboard: "shown",
});
await runCase("preview", "files", fileCase);
}
if (scenario !== "all") {
ad(
"open",
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
"--platform",
"ios",
...deviceArgs,
);
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
if (scenario === "growth") await runLiveGrowth();
else if (scenario === "previews") await runBoundedPreview();
else throw new Error(`Unknown scenario: ${scenario}`);
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
process.exit(0);
}
const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`],
];
for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", ...deviceArgs);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host);
}
process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`);
@@ -1,25 +0,0 @@
# Inline code formatting
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
combined emphasis, strikethrough and linked code, plus bold code in headings,
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
code-to-body size ratio at every heading level; fenced code keeps the code-size
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
line metrics.
Use a development client rebuilt from this checkout. Follow the temporary entry
point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and
links preserving code and emphasized prose fonts.
It runs in the existing `test:android` CI job. To run only this regression locally,
from `packages/app/android` after Expo prebuild:
```sh
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
```
@@ -1,52 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Button, ScrollView, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MarkdownRenderer } from "@/components/markdown/renderer";
const styles = StyleSheet.create((theme) => ({
root: { flex: 1 },
scroll: { backgroundColor: theme.colors.surface0 },
content: { padding: 32, paddingTop: 60 },
title: { color: theme.colors.foreground },
}));
const queryClient = new QueryClient();
const source = [
"Plain: `MMMMiiii`",
"Bold: **`MMMMiiii`**",
"Italic: *`MMMMiiii`*",
"Both: ***`MMMMiiii`***",
"Strike: ~~`MMMMiiii`~~",
"Link: [`MMMMiiii`](https://example.com)",
"# Heading **`MMMMiiii`**",
"## Heading *`MMMMiiii`*",
"### Heading [`MMMMiiii`](https://example.com)",
"#### Heading ~~`MMMMiiii`~~",
"##### Heading ***`MMMMiiii`***",
"###### Heading `MMMMiiii`",
"- List **`MMMMiiii`**",
"> Quote **`MMMMiiii`**",
"Normal **bold** and *italic* prose.",
].join("\n\n");
export default function MarkdownFormattingRegression() {
const [compact, setCompact] = useState(false);
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={styles.root}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.title}>Inline code formatting regression</Text>
<Button
title={compact ? "Use regular text" : "Use compact text"}
onPress={toggleCompact}
/>
<MarkdownRenderer text={source} compact={compact} />
</ScrollView>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
@@ -1,40 +0,0 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
-5
View File
@@ -180,11 +180,6 @@ const test = daemonTest.extend<{
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
// New installs collapse finished turns; specs assert on the rows inside them, so they
// start from the full chat unless they seed their own settings.
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
},
{
daemon: testDaemon,
@@ -338,32 +338,11 @@ export async function delayAgentOlderTimelineResponse(
return delayAgentTimelineResponse(page, agentId, "before");
}
export interface OlderTimelinePagesOptions {
daemonPort?: number;
/**
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
*/
itemPagedHost?: boolean;
}
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
const envelope = parseWebSocketJson(message) as {
message: { payload: { features?: Record<string, unknown> } };
};
delete envelope.message.payload.features?.wholeTurnTimeline;
return JSON.stringify(envelope);
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
options: OlderTimelinePagesOptions = {},
daemonPort?: number,
): Promise<OlderTimelinePagesGate> {
const { daemonPort } = options;
let requestCount = 0;
let responseCount = 0;
let repeatedEntryCount = 0;
@@ -397,8 +376,7 @@ export async function holdAgentOlderTimelinePages(
}
server.send(message);
});
server.onMessage((received) => {
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
@@ -210,30 +210,3 @@ export async function expectOneLineTruncatedFileResult(
expect(overflow.overflow).toBe("hidden");
expect(overflow.whiteSpace).toBe("nowrap");
}
/** Hold real daemon responses until the user has finished the interaction under test. */
export async function holdDirectorySuggestionResponses(page: Page) {
let holding = false;
const pending: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") {
pending.push(() => ws.send(message));
} else {
ws.send(message);
}
});
});
return {
hold() {
holding = true;
},
async release() {
await expect.poll(() => pending.length).toBeGreaterThan(0);
holding = false;
for (const send of pending.splice(0)) send();
},
};
}
@@ -1,90 +0,0 @@
import { mkdirSync } from "node:fs";
import { expect, type Locator, type Page } from "@playwright/test";
import { createMockIdleAgent } from "./archive-tab";
import { seedWorkspace } from "./seed-client";
export async function seedSearchableCommandCenter() {
const seed = await seedWorkspace({
repoPrefix: "command-center-performance-",
title: "Search workspace",
git: false,
});
try {
const agents = [];
for (let index = 0; index < 60; index += 1) {
agents.push(
await createMockIdleAgent(seed.client, {
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
title: `Search agent ${String(index).padStart(3, "0")}`,
}),
);
}
const workspaces = [];
for (let index = 0; index < 40; index += 1) {
const path = `${seed.repoPath}/workspace-${index}`;
mkdirSync(path);
const result = await seed.client.createWorkspace({
source: { kind: "directory", path, projectId: seed.projectId },
title: `Search workspace ${String(index).padStart(3, "0")}`,
});
if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed");
workspaces.push(result.workspace);
}
return { ...seed, agents, workspaces };
} catch (error) {
await seed.cleanup();
throw error;
}
}
export async function observeCommandCenterRowRenders(page: Page) {
await page.addInitScript(() => {
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
});
}
export async function searchCommandCenter(panel: Locator, query: string) {
await panel.getByTestId("command-center-input").fill(query);
}
export async function warmCommandCenterRows(page: Page) {
// FlatList fills its overscan window in timed batches after the first visible frame.
await page.waitForTimeout(600);
await page.evaluate(() => {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
});
}
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const renders = await page.evaluate(() =>
Object.fromEntries(
Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) =>
id.startsWith("CommandCenterResultRow:"),
),
),
);
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
}
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
const rows = panel.locator(
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
);
const count = await rows.count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(total);
}
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
await panel.getByTestId("command-center-results").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
}
@@ -169,42 +169,3 @@ export async function expectTightTwoLineResultSpacing(
.evaluate((element) => element.getBoundingClientRect().top);
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
}
/** Wait for the browser to deliver wheel events before publishing a daemon update. */
export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) {
await page.setViewportSize({ width: 1280, height: 500 });
const viewport = panel.getByTestId("command-center-results");
await viewport.hover();
await page.mouse.wheel(0, 300);
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
const samples = await sampleCommandCenterScroll(panel);
const offset = samples.at(-1)!;
expect(offset).toBeGreaterThan(100);
return offset;
}
async function sampleCommandCenterScroll(panel: Locator): Promise<number[]> {
return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
const samples: number[] = [];
for (let frame = 0; frame < 30; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
samples.push(viewport.scrollTop);
}
return samples;
});
}
export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) {
const samples = await sampleCommandCenterScroll(panel);
expect(Math.min(...samples)).toBe(offset);
expect(Math.max(...samples)).toBe(offset);
}
export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) {
await page.keyboard.press("ArrowDown");
await expect
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
.toBeLessThan(offset);
const second = panel.getByTestId("command-center-results").getByRole("button").nth(1);
await expect(second).toBeInViewport({ ratio: 1 });
}
@@ -87,14 +87,6 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
export async function holdSendButton(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).hover();
await page.mouse.down();
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
await page.mouse.up();
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
@@ -19,9 +19,6 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
// Config updates before provider refresh finishes and dismisses the add sheet.
// Resizing or interacting with the model list must wait for that save to finish.
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
const button = this.page.getByTestId("new-workspace-import-session");
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
await this.page.getByTestId("new-workspace-import-session").click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -86,29 +86,7 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
}
async revealSession(id: string) {
const target = this.page.getByTestId(rowTestId(id));
if ((await target.count()) === 0) {
await this.scrollList(false);
await expect(async () => {
if ((await target.count()) === 0) await this.scrollList(true);
await expect(target).toHaveCount(1, { timeout: 100 });
}).toPass({ timeout: 10_000 });
}
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport();
}
private async scrollList(forward: boolean) {
await this.page
.locator('[data-testid^="import-session-session-"]')
.first()
.evaluate((row, advance) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
element = element.parentElement;
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
element.dispatchEvent(new Event("scroll"));
}, forward);
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
}
async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query);
@@ -122,8 +100,7 @@ export class ImportSessionFlow {
}
async loadMore() {
await this.page.getByTestId("import-session-load-more").click();
await this.revealSession("fixture-root-20");
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async retryProvider(provider: string, label: string) {
@@ -134,7 +111,6 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
}
async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000,
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
);
}
async close() {
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
await this.page.keyboard.press("Escape");
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
}
async expectCommandCenterMatch() {
@@ -5,64 +5,8 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
const PLUGIN_ID = "timeline-transform-e2e";
/** Observe the app's real socket, including fresh sockets after a reload. */
export function trackPluginChatRecovery(page: Page) {
const messages: {
type: string;
payload?: { plugins?: { id: string; clientBundle?: string }[] };
}[] = [];
page.on("websocket", (socket) => {
const collect = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session") messages.push(frame.message);
};
socket.on("framesent", collect);
socket.on("framereceived", collect);
});
return {
reset() {
messages.length = 0;
},
async expectCatalogWithoutRetransmission() {
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "plugin.catalog.get.response" &&
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
),
)
.toBe(true);
const entry = messages
.find((message) => message.type === "plugin.catalog.get.response")
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
expect(entry).toBeDefined();
expect(entry).not.toHaveProperty("clientBundle");
},
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
}
export async function reconnectPluginChat(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
gate.restore();
}
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
@@ -172,7 +116,7 @@ export async function withTimelinePlugin(
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -44,7 +44,6 @@ export interface SeedDaemonClient {
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
clearWorkspaceAttention(workspaceId: string): Promise<void>;
setWorkspaceLabel(input: {
@@ -148,12 +147,7 @@ export interface SeedDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -42,7 +42,6 @@ type HostSection =
| "pair-device"
| "agents"
| "metadata"
| "voice"
| "workspaces"
| "providers"
| "usage"
@@ -58,92 +57,6 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
export async function revealSettingsSidebarSection(
page: Page,
section: SettingsSection,
): Promise<number> {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
.scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollSettled(
page: Page,
offset: number,
): Promise<void> {
// RN Web emits its final scroll callback 100 ms after scrolling stops.
await page.waitForTimeout(200);
await expectSettingsSidebarScrollOffset(page, offset);
}
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
for (let selection = 0; selection < 5; selection += 1) {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Providers", exact: true })
.click();
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Terminals", exact: true })
.click();
await expectSettingsHeader(page, "Terminals");
}
}
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
await page.getByTestId("settings-sidebar-scroll-body").hover();
await page.mouse.wheel(0, -pixels);
}
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size" });
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await scrollBody.hover();
await page.mouse.wheel(0, 2000);
await expect
.poll(() =>
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
)
.toBe(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
.toBeGreaterThan(0);
await scrollBody.hover();
await page.mouse.wheel(0, 1000);
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
return offset;
}
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
@@ -413,25 +326,6 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
function idleAgentReleaseSwitch(page: Page) {
return page
.getByTestId("host-page-idle-runtime-cleanup-card")
.getByRole("switch", { name: "Release idle agents" });
}
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
}
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
await toggle.click();
await expectIdleAgentRelease(page, enabled);
}
export async function openHostSection(
page: Page,
serverId: string,
@@ -63,9 +63,8 @@ export async function seedSidebarFooterPreferences(
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
if (settings.sidebarFooterItems) return;
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click();
await page.getByRole("button", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
@@ -1,4 +1,4 @@
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
import { expect, type TestInfo, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
@@ -9,7 +9,6 @@ import {
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
type OlderTimelinePagesOptions,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
@@ -36,19 +35,6 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
newestPrompt: string;
}
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
newestPrompt: string;
/** Commands in one run, more than an item page holds. */
totalCommands: number;
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
loadedCommands: number;
}
interface ToolCallGroupPositionSnapshot {
top: number;
}
const ITEM_PAGE_SIZE = 40;
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
@@ -140,93 +126,6 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
};
}
/**
* A run of commands longer than an item page, followed by enough short turns that the
* initial tail ends inside the run. Read with item pages, the tail loads the newest
* commands as one group; the older page carries the rest of that run and the turns before
* it, with a further page behind it.
*/
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-item-page-tool-group-",
title: "Item page tool group regression",
});
const olderTurns = 10;
const newerTurns = 12;
const totalCommands = 50;
// A short turn is its prompt and one reply; the run's turn ends with one reply.
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
const shortTurnPrompt = (label: string, index: number) =>
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
const sendTurn = async (prompt: string) => {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
};
for (let index = 0; index < olderTurns; index += 1) {
await sendTurn(shortTurnPrompt("older", index));
}
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
for (let index = 0; index < newerTurns; index += 1) {
await sendTurn(shortTurnPrompt("newer", index));
}
return {
...agent,
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
totalCommands,
loadedCommands,
};
}
export async function useChatDisplay(
page: Page,
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
function toolCallGroup(page: Page, commandCount: number): Locator {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return timeline
.getByTestId("tool-call-group")
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
}
export async function rememberToolCallGroupPosition(
page: Page,
commandCount: number,
): Promise<ToolCallGroupPositionSnapshot> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
const box = await group.boundingBox();
if (!box) {
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
}
return { top: box.y };
}
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
export async function expectToolCallGroupPositionPreserved(
page: Page,
commandCount: number,
before: ToolCallGroupPositionSnapshot,
): Promise<void> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
await expect
.poll(
async () => {
const box = await group.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
},
{ message: `the group of ${commandCount} commands keeps its viewport position` },
)
.toBeLessThanOrEqual(2);
}
export async function scrollThroughOlderHistoryPages(
page: Page,
pageCount: number,
@@ -417,14 +316,13 @@ export async function holdNextOlderTimelinePage(
export async function holdOlderHistoryPages(
page: Page,
agent: MockAgentWorkspace,
options?: OlderTimelinePagesOptions,
daemonPort?: number,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
page,
agent.agentId,
options,
daemonPort,
);
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
@@ -432,17 +330,7 @@ export async function holdOlderHistoryPages(
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
// The loader stays until the prepend settles and the viewport decides whether to
// keep loading. Once it hides, no further page loads without new user intent.
await expect
.poll(
async () => ({
requestedPages: gate.getRequestCount(),
loading: await loader.isVisible(),
}),
{ message: `older history settles after ${count} requested page(s)` },
)
.toEqual({ requestedPages: count, loading: false });
expect(gate.getRequestCount()).toBe(count);
},
expectNoRepeatedEntries() {
expect(gate.getRepeatedEntryCount()).toBe(0);
@@ -535,53 +423,6 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
/**
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
* mid-turn: its prompt is above loaded history. Returns that prompt.
*/
export async function expectOldestLoadedTurnPartial(
page: Page,
prompts: readonly string[],
): Promise<string> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const oldestLoadedPrompt = await timeline
.getByTestId("user-message")
.first()
.evaluate((element) => element.textContent ?? "");
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
const oldestRow = timeline.locator("[data-history-row-id]").first();
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
0,
);
const partialPrompt = prompts[loadedIndex - 1]!;
await expectTimelinePromptNotMounted(page, partialPrompt);
return partialPrompt;
}
/** The reader stays with the turn they were reading once it folds behind its summary. */
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
const [timelineBox, promptBox] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
]);
if (!timelineBox || !promptBox) return false;
const offset = promptBox.y - timelineBox.y;
return offset >= 0 && offset <= timelineBox.height / 2;
},
{ message: "folded turn sits in the upper half of the viewport" },
)
.toBe(true);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
@@ -301,10 +301,3 @@ export async function refreshAllUsage(page: Page): Promise<void> {
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
export async function expectCompactSidebarOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
);
}
@@ -1,99 +0,0 @@
import { rename, mkdir, rmdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
export async function openVoiceSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "voice");
await expectSettingsHeader(page, "Voice");
}
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
}
export async function expectSpeechSiteSummary(
page: Page,
label: SpeechSiteLabel,
summary: string,
): Promise<void> {
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
}
/** Opens the provider sheet for one use site and returns it. */
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
await speechSiteRow(page, label).click();
const sheet = page.getByRole("dialog");
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
return sheet;
}
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
return sheet.getByRole("button", { name: provider, exact: true });
}
export function apiKeyInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "API key" });
}
export function baseUrlInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "Base URL" });
}
export async function saveSpeechSite(sheet: Locator): Promise<void> {
await sheet.getByRole("button", { name: "Save", exact: true }).click();
}
export async function expectSheetClosed(page: Page): Promise<void> {
await expect(page.getByRole("dialog")).toHaveCount(0);
}
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const { config } = await client.getDaemonConfig();
const features = config.features as Record<string, Record<string, unknown>>;
const [feature, site] = siteId.split(".");
return features[feature][site] as Record<string, unknown>;
} finally {
await client.close().catch(() => undefined);
}
}
/** Puts a speech site back to the default provider so later tests start from a known state. */
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const [feature, site] = siteId.split(".");
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
} finally {
await client.close().catch(() => undefined);
}
}
/** Makes the daemon's config file unreadable for the duration of `run`. */
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
const configPath = path.join(paseoHome, "config.json");
const asidePath = `${configPath}.aside`;
await rename(configPath, asidePath);
await mkdir(configPath);
try {
await run();
} finally {
await rmdir(configPath);
await rename(asidePath, configPath);
}
}
@@ -1,47 +1,12 @@
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
-3
View File
@@ -15,9 +15,6 @@
}
},
"production": {
"ios": {
"image": "macos-tahoe-26.6-xcode-27.1"
},
"env": {
"APP_VARIANT": "production"
},
+6 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.1",
"version": "0.13.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -76,8 +76,8 @@
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
@@ -126,14 +126,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.5",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.4",
"react-native-worklets": "0.10.1",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -198,14 +198,6 @@
],
"enableVideo": false
},
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"react-native-worklets"
]
}
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -1,84 +0,0 @@
import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
function formatProviderLabel(provider: Agent["provider"]): string {
if (!provider) {
return "Agent";
}
return provider
.split(/[-_\s]+/)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function resolveAgentLabel(title: string | null | undefined): string | null {
if (typeof title !== "string") {
return null;
}
const normalized = title.trim();
if (!normalized) {
return null;
}
if (normalized.toLowerCase() === "new agent") {
return null;
}
return normalized;
}
export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
const agent =
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
};
}),
);
const icon = useProviderIcon(descriptorState.provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, icon });
}
export function buildAgentDescriptor(input: {
provider: Agent["provider"];
title: string | null;
status: Agent["status"] | null;
pendingPermissionCount: number;
requiresAttention: boolean;
attentionReason: Agent["attentionReason"];
isTurnActive: boolean;
icon: PanelDescriptor["icon"];
}): PanelDescriptor {
const provider = input.provider;
const label = resolveAgentLabel(input.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: input.icon,
statusBucket: input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null,
};
}
@@ -24,7 +24,6 @@ function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
holdReadingPosition() {},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
@@ -265,7 +265,6 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
@@ -317,7 +316,6 @@ describe("useChatOutline", () => {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
},
};
@@ -373,7 +371,6 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const livePrompt = {
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByItemId: timingFor(assistant.id),
timingByAssistantId: timingFor(assistant.id),
}),
};
}
@@ -1,5 +1,4 @@
import type { StreamItem } from "@/types/stream";
import { continuesTurn } from "./turn-membership";
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
@@ -33,7 +32,7 @@ export function findMountedWindowStart(input: {
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
+6 -6
View File
@@ -104,7 +104,7 @@ function layoutFor(input: {
strategy,
streamHead: input.head ?? [],
}),
timingByItemId: timingFor(...(input.timingIds ?? [])),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
it("places one response footer after an adjacent tagged tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2");
const layout = layoutFor({
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
timingIds: [priorAssistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
});
it("recomputes cached history layout when only the live boundary turn changes", () => {
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
expect(footerOwners(first)).toEqual([]);

Some files were not shown because too many files have changed in this diff Show More