mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a05491be5 | ||
|
|
28bb7e29be | ||
|
|
d6d04b0c56 | ||
|
|
20b2dad610 | ||
|
|
58ca9a9b30 | ||
|
|
dfa9977047 | ||
|
|
4f46e094a1 | ||
|
|
dd914c1479 | ||
|
|
025e7ef1d8 | ||
|
|
b4e837a9fe | ||
|
|
f098f77f4a | ||
|
|
f59ebd4fd5 | ||
|
|
aae97b2bdc | ||
|
|
8419c21227 | ||
|
|
5f014498c7 | ||
|
|
4495bda146 | ||
|
|
484c5ba224 | ||
|
|
1b8dbc38ab | ||
|
|
694696e82c | ||
|
|
a8032a1502 | ||
|
|
dc07efe937 | ||
|
|
36892d5be7 | ||
|
|
b9f624f72e | ||
|
|
50623965b4 | ||
|
|
ef58ace424 | ||
|
|
8a542048b6 | ||
|
|
6b7d49f0d8 | ||
|
|
07007a485d | ||
|
|
26ee20b371 | ||
|
|
b12eefb28f | ||
|
|
3a1b146690 | ||
|
|
2ce6681d6e | ||
|
|
63ed15e933 | ||
|
|
9e4aab16cc | ||
|
|
173fca3c0b | ||
|
|
7f6a805e0e | ||
|
|
9bc998fa38 | ||
|
|
67a2459254 | ||
|
|
f14a4cc112 | ||
|
|
a25f4f5065 | ||
|
|
6077bc6219 | ||
|
|
9c7d4ace65 | ||
|
|
8d971876bf | ||
|
|
fc0cbbb323 | ||
|
|
64b0afe010 | ||
|
|
10bc10c99f | ||
|
|
65ce4b590a | ||
|
|
4f9d9d6de2 | ||
|
|
1f3266e2ba | ||
|
|
28834a91ae | ||
|
|
e3bbed3c73 | ||
|
|
42d74c2546 | ||
|
|
678fe308db | ||
|
|
287ca54749 | ||
|
|
902b0c7a16 |
+11
-18
@@ -133,11 +133,12 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -395,7 +396,7 @@ jobs:
|
||||
name: app-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -417,19 +418,11 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Run Android module tests
|
||||
run: npm run test:android --workspace=@getpaseo/app
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
@@ -485,7 +478,7 @@ jobs:
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
@@ -495,7 +488,7 @@ jobs:
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
@@ -505,7 +498,7 @@ jobs:
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
@@ -515,7 +508,7 @@ jobs:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
@@ -525,7 +518,7 @@ jobs:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
@@ -535,7 +528,7 @@ jobs:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
@@ -545,7 +538,7 @@ jobs:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
|
||||
@@ -169,6 +169,34 @@ 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
@@ -85,6 +85,151 @@
|
||||
"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",
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0-beta.2 - 2026-10-28
|
||||
|
||||
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 Chat display settings that group tool calls or collapse 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), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) 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))
|
||||
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||
- 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), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||
- 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 discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||
- 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
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.14.0-beta.2 - 2026-10-28
|
||||
|
||||
插件现在可以为单个 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), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) 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))
|
||||
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||
- 问题卡片新增可点击链接和可选中的描述文本 ([#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), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#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))
|
||||
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||
- 修复模型选择器在打开设置后仍然显示的问题 ([#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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -288,7 +288,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) instead, replacing the subagents list in place (Back returns to the list); 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo
|
||||
`android:production` still builds and installs a release through Expo; use `build:apk` when you
|
||||
need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
|
||||
+5
-44
@@ -340,50 +340,11 @@ Example: adding a new enum value
|
||||
|
||||
### Creation ownership
|
||||
|
||||
Creation execution is owned by the daemon across socket lifetimes. A workspace request
|
||||
can include its initial agent and prompt. The app's `workspace-creation/` module owns
|
||||
chat submission presentation outside React: it reserves final IDs and a message identity,
|
||||
then opens the agent target before asynchronous preparation or a daemon response.
|
||||
The New workspace chat entry requires `creationLifecycle`; empty workspaces, terminal
|
||||
launches, and existing-workspace agent drafts retain their own entry points.
|
||||
|
||||
Pending presentation never enters the directory replica as a fabricated resource.
|
||||
Routes, tabs, and the sidebar retain it until the corresponding authoritative resource
|
||||
has been observed. Files, terminals, scripts, and agent timeline initialization wait for
|
||||
real resources. The creating agent opens in the normal agent panel and composer, showing
|
||||
its submitted configuration. Until the host publishes the agent, its destination cwd is
|
||||
unknown and its controls are read-only. The host publishes the agent before it starts the
|
||||
initial prompt, so prompts wait in the agent's queue until creation completes
|
||||
(`isAgentReadyForPrompts`). `composer/actions.ts` applies that to every prompt path: send
|
||||
and review feedback queue behind earlier prompts, and send now and queue drain leave the
|
||||
queue untouched. The queue drains on adoption or when the initial turn ends. The first
|
||||
message uses composer submission reconciliation; creation never sends it again when the
|
||||
panel mounts. Completion does not navigate, and draft cleanup checks the captured version
|
||||
so it cannot erase newer input.
|
||||
|
||||
A failed creation goes to the existing owner of whatever the host did not create, using
|
||||
the receipt's resources and `failedStage`. Creation has no failure UI of its own:
|
||||
|
||||
- No workspace: the app returns to New workspace with the submitted draft and checkout
|
||||
selection, and shows the error the way that screen shows its own submit errors.
|
||||
- Workspace but no agent: the agent tab becomes an existing-workspace agent draft holding
|
||||
the prompt, in the abandoned create-flow state. Its error and Send retry are that draft's
|
||||
own; the retry creates the agent in the kept workspace.
|
||||
- An agent that exists or may exist, including a failed first prompt: creation completes
|
||||
and the agent tab keeps the agent's own timeline and turn error. Turning it into a draft
|
||||
would create a second agent on Send.
|
||||
|
||||
Queued follow-ups and unsent composer text belong to a composer identity, so they move
|
||||
with the creation: from the form to the creating agent, back to the form or the agent draft
|
||||
on failure, and to the created agent after retry (`transferComposerFollowUps`). The initial
|
||||
prompt stays the form's or draft's input. The agent draft also carries the submitted
|
||||
setup and context attachments. The host ignores `config.cwd` when a create request names a
|
||||
workspace, so a draft's retry starts at the workspace root, not a nested fork cwd.
|
||||
|
||||
Adopted successful attempts release their payloads; a bounded cache of compact receipts
|
||||
absorbs duplicate commands from retained forms. The SDK owns reconnect observation; the
|
||||
app has no resend loop. Pending views survive navigation and pane remount, not app
|
||||
process death. Resource reservations are identities, not ready workspace records.
|
||||
Creation is owned by the daemon across socket lifetimes. A workspace request can include
|
||||
its initial agent and prompt. Workspace readiness is published before provider startup;
|
||||
the app keeps its existing disk-ready navigation and observes the remaining creation.
|
||||
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
|
||||
identities, not ready workspace records.
|
||||
|
||||
The creation journal (`server/creation/`) owns identity and execution for both legacy and
|
||||
modern creation RPCs. Requesting progress never selects a different journal. Existing
|
||||
|
||||
+6
-4
@@ -150,18 +150,20 @@ 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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
- 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 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>`.
|
||||
- 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>`.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -273,6 +273,26 @@ 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
|
||||
@@ -542,6 +562,39 @@ 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`,
|
||||
|
||||
+24
-13
@@ -33,25 +33,36 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
|
||||
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
|
||||
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
|
||||
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
|
||||
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
|
||||
|
||||
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
|
||||
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
|
||||
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
|
||||
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
|
||||
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
|
||||
|
||||
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
|
||||
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
|
||||
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
|
||||
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
|
||||
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
|
||||
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
|
||||
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
|
||||
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
|
||||
container owns Android Back; the pane never subscribes to it.
|
||||
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
|
||||
sheet through `SheetDragArea`, 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 sheet pane bridge
|
||||
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
|
||||
supported navigation context without moving their providers. Keep this capability at the workspace
|
||||
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
|
||||
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
|
||||
See
|
||||
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
|
||||
@@ -410,6 +410,19 @@ bridges the menu's two contexts; the rest pass `null`.
|
||||
|
||||
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
|
||||
|
||||
## Gotcha 8 — Read safe-area insets where the overlay draws
|
||||
|
||||
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
|
||||
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
|
||||
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
|
||||
gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
+23
-4
@@ -52,10 +52,12 @@ is another, with its own icons and hover-revealed actions — but both hand the
|
||||
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
|
||||
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
|
||||
|
||||
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
|
||||
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
|
||||
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
|
||||
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
|
||||
replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab
|
||||
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
|
||||
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
|
||||
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
|
||||
layout; a row with none opens its tab.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -101,6 +103,23 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
|
||||
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
|
||||
not grow, and the keyboard comes up over the field you are typing into.
|
||||
|
||||
## A page that fills the sheet
|
||||
|
||||
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
|
||||
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
|
||||
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
|
||||
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
|
||||
|
||||
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
|
||||
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
|
||||
into a fill page around the same `SheetPane` the workspace's own sheets render. See
|
||||
[explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
|
||||
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
|
||||
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
|
||||
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
|
||||
that opened it.
|
||||
|
||||
## Hover intent
|
||||
|
||||
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
|
||||
@@ -189,7 +208,7 @@ its own.
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it.
|
||||
rarely the height of the page before it. A fill page is the one exception.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
|
||||
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
|
||||
|
||||
+8
-1
@@ -430,8 +430,15 @@ 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. `plugin-examples/lifecycle-logger` registers all
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
|
||||
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.
|
||||
|
||||
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 because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
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.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
+25
-10
@@ -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 bounded request, with older history reachable
|
||||
> agent establishes the daemon's current tail in one complete-turn 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,7 +44,21 @@ 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 one bounded latest tail page. Older history remains
|
||||
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
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
@@ -149,12 +163,13 @@ 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 bounded tail
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
If the canonical window exceeds the cache item target, 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. Never slice items while retaining the pre-slice range; that
|
||||
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
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
@@ -238,11 +253,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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
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
@@ -1 +1 @@
|
||||
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
|
||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
||||
|
||||
Generated
+2357
-4099
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -131,6 +131,7 @@
|
||||
"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",
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
@@ -986,7 +987,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
|
||||
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -1003,8 +1004,10 @@ test.describe("Agent message submission", () => {
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await page.reload();
|
||||
|
||||
await expect(submittedPrompt).toHaveCount(0);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
|
||||
// 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(1);
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
seedRealisticOlderTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
expectToolCallGroupPositionPreserved,
|
||||
rememberToolCallGroupPosition,
|
||||
seedItemPagedToolGroupTimeline,
|
||||
useChatDisplay,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -100,6 +104,32 @@ 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,
|
||||
}) => {
|
||||
|
||||
@@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentFromCompactTrack,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import {
|
||||
backToList,
|
||||
closeSheet,
|
||||
expectNoSheetOpen,
|
||||
expectOneSheetOpen,
|
||||
expectOpenTabCount,
|
||||
openSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
@@ -52,6 +56,10 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
const grandchild = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: child.id, title: "Sheet grandchild" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
@@ -79,12 +87,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await tapSheetBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await test.step("a subagent opens in place of the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expectSubagentRowGone(page, child.id);
|
||||
await expectOneSheetOpen(page);
|
||||
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
|
||||
// seeded agent has no context usage yet.
|
||||
@@ -94,23 +104,51 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("closing the subagent's sheet uncovers the list", async () => {
|
||||
await closeSheet(page, child.title);
|
||||
await test.step("Back returns the sheet to the list", async () => {
|
||||
await backToList(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await expectOneSheetOpen(page);
|
||||
});
|
||||
|
||||
await test.step("Close closes the list with the subagent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowGone(page, child.id);
|
||||
await expectNoSheetOpen(page);
|
||||
});
|
||||
|
||||
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();
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
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 expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
const childSheet = sheet(page, child.title);
|
||||
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
|
||||
const grandchildSheet = sheet(page, grandchild.title);
|
||||
await expectSheetAtSnap(page, grandchild.title);
|
||||
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
|
||||
|
||||
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(grandchildSheet).toHaveCount(0);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectOneSheetOpen(page);
|
||||
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectNoSheetOpen(page);
|
||||
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 openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await openSheetAsTab(page, child.title);
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
@@ -123,7 +161,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
|
||||
await test.step("once its tab closes, the subagent reopens in place of the list", async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
@@ -141,7 +179,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await closeSheet(page, child.title);
|
||||
await backToList(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
sendDraftToQueue,
|
||||
@@ -266,9 +268,7 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("composer and attachments stay available while new workspace agent is being created", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -298,9 +298,8 @@ test.describe("Composer attachments", () => {
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerEditable(page);
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { expectPickerSelected } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
hideCreationLifecycle,
|
||||
holdWorkspaceProvisioning,
|
||||
reportUnknownPromptOutcome,
|
||||
} from "../support/helpers/creation";
|
||||
import type { createCreationScenario } from "../support/helpers/creation";
|
||||
import { expectComposerMode, expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import { expectChatHistoryAttachment } from "../support/helpers/assistant-fork";
|
||||
import { expect } from "../support/fixtures";
|
||||
import { test } from "../support/creation-fixtures";
|
||||
|
||||
@@ -113,38 +105,26 @@ test("new workspace navigation and optimistic prompt precede agent initializatio
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
for (const scenario of [
|
||||
{ suffix: "", prepare: async () => {} },
|
||||
{
|
||||
name: "an agent draft",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openAgentDraft(),
|
||||
button: "Send message",
|
||||
expectWorkspace: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
|
||||
},
|
||||
{
|
||||
name: "a new workspace",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openWorkspaceForm("local"),
|
||||
button: "Create",
|
||||
expectWorkspace: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.expectOneCreatedWorkspace(),
|
||||
suffix: " after remount",
|
||||
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.evictAndReturnToDraft(),
|
||||
},
|
||||
]) {
|
||||
test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({
|
||||
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Retry this agent after its startup fails.";
|
||||
await entry.open(creation);
|
||||
await creation.submitPrompt(prompt, entry.button);
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectFailedAgentDraft(prompt);
|
||||
await entry.expectWorkspace(creation);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") });
|
||||
await creation.resendFailedDraft();
|
||||
await creation.expectPromptVisible(prompt);
|
||||
await entry.expectWorkspace(creation);
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
@@ -191,356 +171,3 @@ for (const entry of [
|
||||
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`a held ${isolation} workspace opens the normal agent panel and queues a follow-up`, async ({
|
||||
creation,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates for the first workspace prompt.";
|
||||
const followUp = "emit 1 coalesced agent stream updates for the queued follow-up.";
|
||||
const draft = "Keep this unsent draft through adoption.";
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm(isolation);
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
|
||||
await test.step("the final agent panel paints before any host response", async () => {
|
||||
expect(request.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
|
||||
expect(request.agent!.agentId).toMatch(/^[a-f0-9-]{36}$/);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${request.agent!.agentId}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await creation.expectPromptVisible(prompt);
|
||||
await creation.expectNormalAgentComposer();
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
});
|
||||
|
||||
await test.step("a follow-up waits in the queue instead of reaching the agent", async () => {
|
||||
await creation.queueFollowUp(followUp);
|
||||
await creation.typeComposerDraft(draft);
|
||||
expect(provisioning.agentPrompts()).toBe(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("held-normal-panel.png") });
|
||||
});
|
||||
|
||||
await test.step("adoption keeps the same agent, draft and queue order", async () => {
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
|
||||
await creation.expectUserMessagesInOrder([prompt, followUp]);
|
||||
expect(provisioning.agentPrompts()).toBe(1);
|
||||
await creation.expectComposerDraft(draft);
|
||||
await creation.expectTimelineSettled();
|
||||
await page.screenshot({ path: testInfo.outputPath("adopted-normal-panel.png") });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a held workspace on compact shows the final agent prompt and composer", async ({
|
||||
creation,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await creation.submitPrompt("Show my prompt before workspace provisioning.", "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
|
||||
await creation.expectPromptVisible("Show my prompt before workspace provisioning.");
|
||||
await creation.expectNormalAgentComposer();
|
||||
await creation.expectAgentCannotBeStoppedYet();
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-held.png") });
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
|
||||
await creation.expectInitialPromptOnce("Show my prompt before workspace provisioning.");
|
||||
});
|
||||
|
||||
test("workspace creation recovers a lost response without replaying the first prompt", async ({
|
||||
creation,
|
||||
workspacePromptRetry,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
workspacePromptRetry.holdAcknowledgement();
|
||||
await creation.submitPrompt("Recover this workspace prompt once.", "Create");
|
||||
await workspacePromptRetry.waitForDeliveredPrompt();
|
||||
await creation.expectPromptVisible("Recover this workspace prompt once.");
|
||||
await creation.evictAndReturnToDraft();
|
||||
await workspacePromptRetry.disconnectAndReconnect();
|
||||
await workspacePromptRetry.expectSameAgentAndMessage();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a pending workspace survives leaving and remounting before any daemon event", async ({
|
||||
creation,
|
||||
page,
|
||||
}) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.submitPrompt("Keep this pending workspace across remount.", "Create");
|
||||
await provisioning.waitForRequest();
|
||||
await creation.expectPromptVisible("Keep this pending workspace across remount.");
|
||||
await creation.evictAndReturnToDraft();
|
||||
await creation.expectPromptVisible("Keep this pending workspace across remount.");
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a failed workspace returns to New workspace with the prompt and its error", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Keep my prompt when the workspace fails.";
|
||||
await provisioningFailure.arm();
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitRepeatedly("Create", prompt);
|
||||
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-workspace-returned.png") });
|
||||
});
|
||||
|
||||
test("a failed workspace preserves its non-default branch through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}) => {
|
||||
const prompt = "Keep the selected worktree base when provisioning fails.";
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.selectWorktreeBranch("recovery-base");
|
||||
await provisioningFailure.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
const first = await provisioning.waitForRequest();
|
||||
expect(first.source).toMatchObject({
|
||||
kind: "worktree",
|
||||
action: "branch-off",
|
||||
refName: "refs/heads/recovery-base",
|
||||
});
|
||||
provisioning.release();
|
||||
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
|
||||
|
||||
await expectPickerSelected(page, "recovery-base");
|
||||
await creation.resubmitWorkspace();
|
||||
const second = await provisioning.waitForRequests(2);
|
||||
expect(second.source).toMatchObject({
|
||||
kind: "worktree",
|
||||
action: "branch-off",
|
||||
refName: "refs/heads/recovery-base",
|
||||
});
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a failed workspace keeps its queued follow-up and unsent text through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates for the resubmitted workspace.";
|
||||
const followUp = "emit 1 coalesced agent stream updates for the resubmitted follow-up.";
|
||||
const draft = "Keep this unsent text through the failed workspace.";
|
||||
await provisioningFailure.arm();
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await provisioning.waitForRequest();
|
||||
await creation.queueFollowUp(followUp);
|
||||
await creation.typeComposerDraft(draft);
|
||||
provisioning.release();
|
||||
|
||||
await test.step("New workspace shows the prompt, its error and the queued follow-up", async () => {
|
||||
await creation.expectReturnedToNewWorkspace(
|
||||
prompt,
|
||||
"Provisioning outcome is uncertain for test",
|
||||
);
|
||||
await creation.expectQueuedFollowUp(followUp);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-workspace-follow-up.png") });
|
||||
});
|
||||
|
||||
await test.step("resubmitting delivers the prompt, then the follow-up, once", async () => {
|
||||
await creation.resubmitWorkspace();
|
||||
await provisioning.waitForRequests(2);
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectUserMessagesInOrder([prompt, followUp]);
|
||||
await creation.expectComposerDraft(draft);
|
||||
await page.screenshot({ path: testInfo.outputPath("resubmitted-workspace-follow-up.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed agent start keeps its queued follow-up and unsent text through retry", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates for the retried agent.";
|
||||
const followUp = "emit 1 coalesced agent stream updates for the retried follow-up.";
|
||||
const draft = "Keep this unsent text through the failed agent start.";
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await creation.queueFollowUp(followUp);
|
||||
await creation.typeComposerDraft(draft);
|
||||
await startup.fail();
|
||||
|
||||
await test.step("the agent draft shows the prompt, its error and the queued follow-up", async () => {
|
||||
await creation.expectFailedAgentDraft(prompt);
|
||||
await creation.expectQueuedFollowUp(followUp);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-agent-follow-up.png") });
|
||||
});
|
||||
|
||||
await test.step("retrying delivers the prompt, then the follow-up, once", async () => {
|
||||
await creation.resendFailedDraft();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectUserMessagesInOrder([prompt, followUp]);
|
||||
await creation.expectComposerDraft(draft);
|
||||
await page.screenshot({ path: testInfo.outputPath("retried-agent-follow-up.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("a forked worktree agent that fails to start keeps its setup and context through retry", async ({
|
||||
creation,
|
||||
lateStartup,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Keep this fork's setup, image and history through the retry.";
|
||||
await creation.openNestedAgentFork("worktree");
|
||||
await creation.attachImage();
|
||||
const startup = await lateStartup.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await startup.fail();
|
||||
|
||||
await test.step("the agent draft keeps the fork's configuration and context", async () => {
|
||||
await creation.expectFailedAgentDraft(prompt);
|
||||
await expectComposerModel(page, "e2e-fast-stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
await creation.expectImageVisible();
|
||||
await expectChatHistoryAttachment(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-fork-draft.png") });
|
||||
});
|
||||
|
||||
// An agent created in an existing workspace starts at the workspace root: the host ignores
|
||||
// config.cwd when the request names a workspace, so the nested cwd is not asserted here.
|
||||
await test.step("retrying creates one agent in the worktree with that configuration and context", async () => {
|
||||
await creation.resendFailedDraft();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectAgentConfig({ model: "e2e-fast-stream", modeId: "load-test" });
|
||||
await creation.expectImageVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath("retried-fork-agent.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("a forked worktree whose workspace fails keeps its context through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Keep this fork's history when its workspace fails.";
|
||||
await creation.openNestedAgentFork("worktree");
|
||||
await provisioningFailure.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
|
||||
await test.step("New workspace shows the prompt, its error and the fork's context", async () => {
|
||||
await creation.expectReturnedToNewWorkspace(
|
||||
prompt,
|
||||
"Provisioning outcome is uncertain for test",
|
||||
);
|
||||
await expectChatHistoryAttachment(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-fork-workspace.png") });
|
||||
});
|
||||
|
||||
await test.step("resubmitting creates one agent with that context", async () => {
|
||||
await creation.resubmitWorkspace();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath("resubmitted-fork-workspace.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("an unsupported host keeps the draft and asks for an update before creating anything", async ({
|
||||
creation,
|
||||
page,
|
||||
}) => {
|
||||
const host = await hideCreationLifecycle(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Retain my unsupported host draft.", "Create");
|
||||
await expect(
|
||||
page
|
||||
.getByText("Update this host to create a workspace with an agent.", { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
host.expectNoCreationRequest();
|
||||
});
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`a fork keeps its image and nested cwd in the optimistic and real ${isolation} agent`, async ({
|
||||
creation,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
const cwd = await creation.openNestedAgentFork(isolation);
|
||||
await creation.attachImage();
|
||||
await creation.submitPrompt("Keep this image and nested cwd.", "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
expect(request.agent!.config?.cwd).toBe(cwd);
|
||||
expect(request.agent!.images).toHaveLength(1);
|
||||
await creation.expectPromptVisible("Keep this image and nested cwd.");
|
||||
await creation.expectImageVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-image-nested-cwd.png") });
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectNestedAgentCwd();
|
||||
await creation.expectImageVisible();
|
||||
await creation.expectInitialPromptOnce("Keep this image and nested cwd.");
|
||||
});
|
||||
}
|
||||
|
||||
test("closing the pending agent tab leaves host creation running", async ({ creation, page }) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Finish creation after I close the tab.", "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
await creation.expectPromptVisible("Finish creation after I close the tab.");
|
||||
await creation.closePendingAgent(request.agent!.agentId!);
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectInitialPromptOnce("Finish creation after I close the tab.");
|
||||
});
|
||||
|
||||
test("an uncertain prompt receipt keeps the created agent tab and its timeline", async ({
|
||||
creation,
|
||||
page,
|
||||
}) => {
|
||||
await reportUnknownPromptOutcome(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
const prompt = "emit 1 coalesced agent stream updates for an uncertain first prompt.";
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await creation.expectAssistantReply();
|
||||
await creation.expectAgentNotWorking();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectInitialPromptOnce(prompt);
|
||||
await creation.expectNoCreationRecovery();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("current app requires a host update for chat workspaces and preserves existing-workspace agent creation", async ({
|
||||
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
|
||||
page,
|
||||
project,
|
||||
client,
|
||||
@@ -104,34 +104,49 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Create");
|
||||
await gate.waitForRequest();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(
|
||||
page
|
||||
.getByText("Update this host to create a workspace with an agent.", { exact: true })
|
||||
.first(),
|
||||
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(0);
|
||||
expect(
|
||||
(await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) => workspace.projectId === project.projectId,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, project.workspaceId));
|
||||
const workspaces = (await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
await expectPromptOnce(client, first.id, prompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
|
||||
).toHaveText(prompt);
|
||||
|
||||
await createAgentTabFromMenu(page);
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForRequest();
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const agent = (await client.fetchAgents()).entries[0]!.agent;
|
||||
expect(agent.workspaceId).toBe(project.workspaceId);
|
||||
await client.waitForFinish(agent.id, 20_000);
|
||||
await expectPromptOnce(client, agent.id, secondPrompt);
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
)!.agent;
|
||||
expect(second.workspaceId).toBe(first.workspaceId);
|
||||
await client.waitForFinish(second.id, 20_000);
|
||||
await expectPromptOnce(client, second.id, secondPrompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
|
||||
).toHaveText(secondPrompt);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ visible: true })
|
||||
.filter({ hasText: secondPrompt }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
|
||||
});
|
||||
|
||||
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -296,8 +297,187 @@ 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,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observeCreationRequests } from "../support/helpers/creation";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
fillComposerDraft,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
@@ -96,7 +95,6 @@ test.describe("New workspace composer draft", () => {
|
||||
|
||||
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
const requests = await observeCreationRequests(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -136,9 +134,6 @@ test.describe("New workspace composer draft", () => {
|
||||
}, DRAFT);
|
||||
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
// Navigation now precedes creation; wait for success before checking consumed input.
|
||||
await expect.poll(requests.workspaceRequests).toBe(1);
|
||||
await requests.settled();
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceDraft(page, "");
|
||||
} finally {
|
||||
@@ -147,11 +142,10 @@ test.describe("New workspace composer draft", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({
|
||||
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
|
||||
const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`;
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -163,23 +157,25 @@ test("fullscreen creation opens the inline agent composer and keeps follow-up ed
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await delay.waitForCreateRequest();
|
||||
|
||||
await test.step("submission opens the normal agent panel inline", async () => {
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await test.step("submission returns inline with the original draft and spinner", async () => {
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
});
|
||||
await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => {
|
||||
await fillComposerDraft(page, followUpDraft);
|
||||
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(followUpDraft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await exitComposerFullscreen(page);
|
||||
});
|
||||
delay.release();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
|
||||
await expect(composerLocator(page)).toHaveValue(followUpDraft);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
} finally {
|
||||
|
||||
@@ -192,16 +192,11 @@ test.describe("New Workspace dictation submit", () => {
|
||||
await harness.waitForCreateRequest();
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
TRANSCRIPT,
|
||||
);
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(composer).toBeEditable();
|
||||
await expect(composer).toHaveValue(TRANSCRIPT);
|
||||
await expect(composer).not.toBeEditable();
|
||||
|
||||
harness.failWorkspaceCreation();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
await expect(composer).toBeEditable();
|
||||
await expect(composer).toHaveValue(TRANSCRIPT);
|
||||
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
|
||||
|
||||
@@ -504,7 +504,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the final agent tab before agent creation resolves", async ({ page }) => {
|
||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||
@@ -568,10 +568,10 @@ test.describe("New workspace flow", () => {
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||
await expect(draftTabs).toHaveCount(0);
|
||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(
|
||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||
).toHaveCount(1, { timeout: 30_000 });
|
||||
).toHaveCount(0);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
await expect(
|
||||
|
||||
@@ -1,29 +1,90 @@
|
||||
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 } from "../support/fixtures";
|
||||
import { metroTest as test, expect, type Page } 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 {
|
||||
expectTimelinePromptVisible,
|
||||
expectFoldedTurnAtReadingPosition,
|
||||
expectOldestLoadedTurnPartial,
|
||||
holdOlderHistoryPages,
|
||||
openAgentTimeline,
|
||||
scrollThroughOlderHistoryPages,
|
||||
expectTimelinePromptVisible,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
|
||||
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 ({
|
||||
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 }) => {
|
||||
@@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis
|
||||
observationSockets.add(socket);
|
||||
});
|
||||
});
|
||||
const workspace = await seedWorkspace({
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.2.5",
|
||||
serverIdPrefix: "srv_old_pagination_",
|
||||
repoPrefix: "timeline-old-daemon-pagination-",
|
||||
port: daemon.port,
|
||||
});
|
||||
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,
|
||||
};
|
||||
const { agent, serverId } = published;
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await published.seedBrowser(page);
|
||||
|
||||
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,
|
||||
"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.");
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
expect(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("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("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, workspace.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(serverId, agent.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 agent.cleanup();
|
||||
await daemon.close();
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -214,3 +216,66 @@ 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -479,3 +479,76 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -274,3 +275,123 @@ 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
|
||||
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -115,23 +122,27 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
|
||||
});
|
||||
});
|
||||
|
||||
function appendCompletedCalls(extraCompletedCalls: number) {
|
||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||
throw new Error("Expected the first completed tool call before release");
|
||||
}
|
||||
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||
sendToPage?.(
|
||||
cloneToolCallWithId(
|
||||
firstCompletedMessage as WebSocketMessage,
|
||||
`${firstCallId}:${sequenceOffset + index}`,
|
||||
sequenceOffset + index + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
sequenceOffset += extraCompletedCalls;
|
||||
}
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
appendCompletedCalls,
|
||||
release(extraCompletedCalls = 0) {
|
||||
isHolding = false;
|
||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||
throw new Error("Expected the first completed tool call before release");
|
||||
}
|
||||
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||
sendToPage?.(
|
||||
cloneToolCallWithId(
|
||||
firstCompletedMessage as WebSocketMessage,
|
||||
`${firstCallId}:${index}`,
|
||||
index + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
sequenceOffset = extraCompletedCalls;
|
||||
appendCompletedCalls(extraCompletedCalls);
|
||||
for (const message of heldMessages.splice(0)) {
|
||||
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
||||
}
|
||||
@@ -139,17 +150,24 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
|
||||
};
|
||||
}
|
||||
|
||||
async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
|
||||
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
|
||||
await page.addInitScript((display) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
|
||||
);
|
||||
});
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function createOverviewAgent(page: Page, title: string) {
|
||||
await configureOverviewToolCalls(page);
|
||||
async function createOverviewAgent(
|
||||
page: Page,
|
||||
title: string,
|
||||
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
|
||||
) {
|
||||
await configureOverviewToolCalls(page, chatDisplay);
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-overview-sheet-",
|
||||
title,
|
||||
@@ -204,6 +222,36 @@ function readScrollMetrics(root: HTMLElement) {
|
||||
};
|
||||
}
|
||||
|
||||
const FIRST_CALL_DETAILS = "export function ConversationList()";
|
||||
|
||||
function firstToolCall(page: Page): Locator {
|
||||
return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first();
|
||||
}
|
||||
|
||||
async function openToolDetails(badge: Locator): Promise<void> {
|
||||
await badge.getByRole("button").first().click();
|
||||
}
|
||||
|
||||
async function timelineRowOf(locator: Locator) {
|
||||
const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle();
|
||||
if (!row) throw new Error("Expected the locator inside a timeline row");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */
|
||||
async function isInsideRow(
|
||||
locator: Locator,
|
||||
row: Awaited<ReturnType<typeof timelineRowOf>>,
|
||||
): Promise<boolean> {
|
||||
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
|
||||
}
|
||||
|
||||
async function topOf(row: Locator): Promise<number> {
|
||||
const box = await row.boundingBox();
|
||||
if (!box) throw new Error("Expected a visible timeline row");
|
||||
return box.y;
|
||||
}
|
||||
|
||||
test.describe("compact overview tool calls", () => {
|
||||
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
|
||||
|
||||
@@ -219,8 +267,16 @@ test.describe("compact overview tool calls", () => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
|
||||
await gate.waitForFirstCompleted();
|
||||
|
||||
const single = firstToolCall(page);
|
||||
await expect(single).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group")).toHaveCount(0);
|
||||
await openToolDetails(single);
|
||||
await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible();
|
||||
await page.getByTestId("tool-call-sheet-close").click();
|
||||
gate.appendCompletedCalls(1);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
|
||||
await group.click();
|
||||
|
||||
const sheet = page.getByTestId("tool-call-group-sheet");
|
||||
@@ -261,27 +317,86 @@ test.describe("compact overview tool calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
|
||||
test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls", display);
|
||||
await recordReactRenders(page);
|
||||
try {
|
||||
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const lone = firstToolCall(page);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
const details = page.getByText(FIRST_CALL_DETAILS);
|
||||
|
||||
await test.step("a lone tool call shows as itself", async () => {
|
||||
await expect(lone).toBeVisible();
|
||||
await expect(group).toHaveCount(0);
|
||||
});
|
||||
|
||||
await gate.waitForSecondRunning();
|
||||
await test.step("opening it re-renders that tool call and nothing else", async () => {
|
||||
await resetReactRenders(page);
|
||||
await openToolDetails(lone);
|
||||
await expect(details).toBeVisible();
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.AgentStreamView ?? 0).toBe(0);
|
||||
expect(renders.StreamRowContent ?? 0).toBe(0);
|
||||
expect(renders.ToolCall).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("the second call forms an open group around the opened call", async () => {
|
||||
// Reading above the tail, the row holds still; at the tail the new rows push it up.
|
||||
await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 });
|
||||
const loneTop = await topOf(lone);
|
||||
const loneRow = await timelineRowOf(lone);
|
||||
await resetReactRenders(page);
|
||||
gate.releaseSecondRunning();
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
|
||||
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
|
||||
expect(await isInsideRow(group, loneRow)).toBe(true);
|
||||
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
|
||||
expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("closing and reopening the group keeps the call open", async () => {
|
||||
await openToolDetails(group);
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||
await expect(details).toHaveCount(0);
|
||||
await openToolDetails(group);
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
|
||||
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("forms a closed group when the lone tool call was left closed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls");
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const lone = firstToolCall(page);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(lone).toBeVisible();
|
||||
await expect(group).toHaveCount(0);
|
||||
|
||||
await gate.waitForSecondRunning();
|
||||
const loneTop = await topOf(lone);
|
||||
gate.releaseSecondRunning();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
||||
const header = group.getByRole("button").first();
|
||||
await header.click();
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
|
||||
gate.release(1);
|
||||
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface ShimmerEvidence {
|
||||
animationDuration: string;
|
||||
endPx: number;
|
||||
@@ -14,127 +12,6 @@ interface ShimmerEvidence {
|
||||
startPx: number;
|
||||
}
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { event?: { item?: { status?: string } } } };
|
||||
};
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
if (!item) {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.status = status;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let secondCallId: string | null = null;
|
||||
let secondRunningMessage: WebSocketMessage | null = null;
|
||||
let pauseAfterSecondRunning = false;
|
||||
let releaseSecondRequested = false;
|
||||
let forwardSecondRunning: (() => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
let resolveSecondRunning!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
const secondRunning = new Promise<void>((resolve) => {
|
||||
resolveSecondRunning = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (secondRunningMessage) {
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningMessage = null;
|
||||
}
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseAfterSecondRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
if (!toolCall) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
ws.send(message);
|
||||
if (toolCall.status === "completed") {
|
||||
resolveFirstCompleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (toolCall.callId === secondCallId) {
|
||||
const transformedMessage =
|
||||
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
|
||||
secondRunningMessage = transformedMessage;
|
||||
pauseAfterSecondRunning = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
forwardSecondRunning?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
|
||||
return locator.evaluate((root) => {
|
||||
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
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> {
|
||||
@@ -19,7 +40,10 @@ async function seedAnsweredAgent(title: string) {
|
||||
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.");
|
||||
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;
|
||||
}
|
||||
@@ -36,17 +60,84 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
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(/^Wrote \d+ messages?, edited 1 file/);
|
||||
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(
|
||||
@@ -57,6 +148,9 @@ test.describe("Turn collapse", () => {
|
||||
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);
|
||||
@@ -65,3 +159,455 @@ test.describe("Turn collapse", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test("long sender attribution stays on one line in compact chat", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
|
||||
);
|
||||
const recipient = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-attribution-",
|
||||
title: "Implementation",
|
||||
});
|
||||
try {
|
||||
const title = "Reviewer checking the compact chat timeline and sender attribution";
|
||||
const sender = await recipient.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: recipient.cwd,
|
||||
workspaceId: recipient.workspaceId,
|
||||
title,
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await recipient.client.sendAgentMessage(
|
||||
recipient.agentId,
|
||||
"The sender label stays on one line, even when the agent has a long name.",
|
||||
{ sourceAgentId: sender.id },
|
||||
);
|
||||
await openAgentRoute(page, recipient);
|
||||
await expectComposerVisible(page);
|
||||
const attribution = page.getByRole("button", { name: `Sent by ${title}`, exact: true });
|
||||
await expect(attribution).toBeVisible();
|
||||
await expectSenderTruncatedWithinMessage(attribution, title);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: testInfo.outputPath("sender-compact-390.png") });
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await expectSenderTruncatedWithinMessage(attribution, title);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await page.screenshot({ path: testInfo.outputPath("sender-compact-320.png") });
|
||||
} 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 expectSenderTruncatedWithinMessage(attribution: Locator, title: string) {
|
||||
const label = attribution.getByText(`Sent by ${title}`, { exact: true });
|
||||
await expect(label).toHaveCSS("white-space", "nowrap");
|
||||
await expect(label).toHaveCSS("text-overflow", "ellipsis");
|
||||
const bounds = await label.evaluate((element) => ({
|
||||
textWidth: element.scrollWidth,
|
||||
visibleWidth: element.clientWidth,
|
||||
right: element.getBoundingClientRect().right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(bounds.textWidth).toBeGreaterThan(bounds.visibleWidth);
|
||||
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import {
|
||||
expectAgentAbsentFromWorkspaceLayout,
|
||||
seedForeignPinnedAgentTab,
|
||||
} from "../support/helpers/workspace-layout";
|
||||
import { clickNewChat } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -105,6 +108,83 @@ 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,6 +6,7 @@ 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: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Native user message preview
|
||||
|
||||
Open a chat with a user message containing 16 newline-separated lines of
|
||||
`Preview line N`, with N from 1 to 16. Scroll the message into view on a
|
||||
phone-sized Android emulator or iOS simulator. Its text must exceed the 240-point preview cap,
|
||||
while its expanded bubble and toggle fit in the chat viewport. Close any other
|
||||
expanded messages so the toggle labels identify this message.
|
||||
|
||||
Replay `native-user-message-preview.android.ad` or
|
||||
`native-user-message-preview.ios.ad` through the existing mobile runner or
|
||||
agent-device. It checks that a clipped message offers Show more and
|
||||
that two expand/collapse round trips keep both controls reachable. Before the
|
||||
fix, the first Show more assertion fails because native layout reports the
|
||||
capped height as the full content height.
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
close
|
||||
@@ -12,6 +12,22 @@ 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.
|
||||
|
||||
With this fixture open in an Android Agent Device session, run:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/markdown-formatting/android.mjs <session>
|
||||
```
|
||||
|
||||
The runner switches to paragraphs with long inline code at a fixed 300 dp width.
|
||||
It compares the native Markdown host’s bottom with its last TextView’s bottom,
|
||||
then repeats in compact mode and after restoring regular text. More than one
|
||||
pixel of surplus fails. Keep the whole fixture visible: clipped accessibility
|
||||
bounds cannot establish measurement parity.
|
||||
|
||||
`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and
|
||||
compares them with the shipped headers. This catches missing C++ fields in the
|
||||
measurement path, which the Kotlin tests cannot exercise.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const session = process.argv[2];
|
||||
assert(session, "Pass an Android agent-device session serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function assertMeasurementParity(mode) {
|
||||
const snapshot = JSON.parse(ad("snapshot", "--raw", "--json"));
|
||||
assert.equal(snapshot.success, true);
|
||||
const nodes = snapshot.data.nodes;
|
||||
let last = nodes.find((node) => node.label?.endsWith("End of measurement fixture."));
|
||||
assert(last?.rect, "The last paragraph must be visible, including its bottom");
|
||||
// EnrichedMarkdownText exposes paragraph accessibility nodes inside its TextView;
|
||||
// EnrichedMarkdown exposes the TextViews themselves. Measure the native child.
|
||||
let host = nodes.find((node) => node.index === last.parentIndex);
|
||||
if (host?.type === "android.widget.TextView") {
|
||||
last = host;
|
||||
host = nodes.find((node) => node.index === last.parentIndex);
|
||||
}
|
||||
assert.equal(host?.type, "android.widget.FrameLayout");
|
||||
const viewport = nodes.find((node) => node.type === "android.widget.ScrollView");
|
||||
const footer = nodes.find((node) => node.label === "End of Markdown host");
|
||||
assert(viewport?.rect && footer?.rect, "The fixture and footer must be visible");
|
||||
assert(
|
||||
host.rect.y >= viewport.rect.y &&
|
||||
footer.rect.y + footer.rect.height <= viewport.rect.y + viewport.rect.height,
|
||||
"The whole fixture must fit in the viewport; clipped bounds cannot prove parity",
|
||||
);
|
||||
const hostBottom = host.rect.y + host.rect.height;
|
||||
const childBottom = last.rect.y + last.rect.height;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
host: host.rect,
|
||||
lastChild: last.rect,
|
||||
surplus: hostBottom - childBottom,
|
||||
}),
|
||||
);
|
||||
assert(
|
||||
Math.abs(hostBottom - childBottom) <= 1,
|
||||
`Markdown reserves blank space in ${mode}: host bottom ${hostBottom}, last child bottom ${childBottom}`,
|
||||
);
|
||||
}
|
||||
|
||||
ad("press", 'label="Show wrapping fixture"');
|
||||
ad("wait", "text", "End of Markdown host", "10000");
|
||||
assertMeasurementParity("regular");
|
||||
ad("press", 'label="Use compact text"');
|
||||
ad("wait", "text", "Use regular text", "10000");
|
||||
assertMeasurementParity("compact");
|
||||
ad("press", 'label="Use regular text"');
|
||||
ad("wait", "text", "Use compact text", "10000");
|
||||
assertMeasurementParity("regular restored");
|
||||
console.log("PASS Android Markdown host measurement matches its last child");
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, ScrollView, Text } from "react-native";
|
||||
import { Button, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
@@ -10,6 +10,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
scroll: { backgroundColor: theme.colors.surface0 },
|
||||
content: { padding: 32, paddingTop: 60 },
|
||||
title: { color: theme.colors.foreground },
|
||||
wrapping: { width: 300 },
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -32,7 +33,15 @@ const source = [
|
||||
"Normal **bold** and *italic* prose.",
|
||||
].join("\n\n");
|
||||
|
||||
const wrappingSource = [
|
||||
"Measurement parity. Take a repo with a gitignored `.env` in the main checkout and a `.worktreeinclude` containing `.env`.",
|
||||
"Every creation path goes through `createPaseoWorktreeWorkflow` in `worktree-session.ts:341`, before `createAgent` in `creation/index.ts:160-174` starts.",
|
||||
"The built-in plugin lives in `plugins/worktree-include/index.server.ts` and copies `paseo.json` beside `.worktreeinclude`. End of measurement fixture.",
|
||||
].join("\n\n");
|
||||
|
||||
export default function MarkdownFormattingRegression() {
|
||||
const [wrapping, setWrapping] = useState(false);
|
||||
const toggleWrapping = useCallback(() => setWrapping((value) => !value), []);
|
||||
const [compact, setCompact] = useState(false);
|
||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||
return (
|
||||
@@ -44,7 +53,14 @@ export default function MarkdownFormattingRegression() {
|
||||
title={compact ? "Use regular text" : "Use compact text"}
|
||||
onPress={toggleCompact}
|
||||
/>
|
||||
<MarkdownRenderer text={source} compact={compact} />
|
||||
<Button
|
||||
title={wrapping ? "Show formatting fixture" : "Show wrapping fixture"}
|
||||
onPress={toggleWrapping}
|
||||
/>
|
||||
<View style={wrapping ? styles.wrapping : undefined}>
|
||||
<MarkdownRenderer text={wrapping ? wrappingSource : source} compact={compact} />
|
||||
</View>
|
||||
<Text style={styles.title}>End of Markdown host</Text>
|
||||
</ScrollView>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -11,24 +11,27 @@ import {
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
|
||||
interface StartupGate {
|
||||
release(): Promise<void>;
|
||||
fail(): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Holds every later workspace agent's startup until it is released or failed once. */
|
||||
async function installStartupGate(): Promise<StartupGate> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const gate = path.join(plugin.directory, "startup.txt");
|
||||
const failure = path.join(plugin.directory, "startup-failure.txt");
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(gate, "hold");
|
||||
await writeFile(failure, "fail");
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
export const test = base.extend<{
|
||||
startup: { release(): Promise<void>; fail(): Promise<void> };
|
||||
promptRejection: void;
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}>({
|
||||
startup: async ({ page }, provide) => {
|
||||
void page;
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const gate = path.join(plugin.directory, "startup.txt");
|
||||
const failure = path.join(plugin.directory, "startup-failure.txt");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(gate, "hold");
|
||||
await writeFile(failure, "fail");
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.session_open", async ({ request }) => {
|
||||
@@ -47,88 +50,17 @@ export default function contribute(server) {
|
||||
return request;
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
return {
|
||||
release: () => rm(gate, { force: true }),
|
||||
// Replacing the command must not expose a truncated, empty file to the hook.
|
||||
fail: () => rename(failure, gate),
|
||||
async dispose() {
|
||||
await rm(gate, { force: true });
|
||||
await client.removePlugin("lifecycle-logger");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await plugin.cleanup();
|
||||
await client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
startup: StartupGate;
|
||||
/** A startup gate installed mid-test, after agents that must start normally. */
|
||||
lateStartup: { arm(): Promise<StartupGate> };
|
||||
promptRejection: void;
|
||||
/** Fails the next workspace request once it is armed. */
|
||||
provisioningFailure: { arm(): Promise<void> };
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
workspacePromptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}>({
|
||||
startup: async ({ page }, provide) => {
|
||||
void page;
|
||||
const gate = await installStartupGate();
|
||||
try {
|
||||
await provide(gate);
|
||||
} finally {
|
||||
await gate.dispose();
|
||||
}
|
||||
},
|
||||
lateStartup: async ({ page }, provide) => {
|
||||
void page;
|
||||
const gates: StartupGate[] = [];
|
||||
try {
|
||||
await provide({
|
||||
arm: async () => {
|
||||
const gate = await installStartupGate();
|
||||
gates.push(gate);
|
||||
return gate;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
for (const gate of gates) await gate.dispose();
|
||||
}
|
||||
},
|
||||
provisioningFailure: async ({ page, creation }, provide) => {
|
||||
void page;
|
||||
void creation;
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
const failure = path.join(plugin.directory, "provisioning-failure.txt");
|
||||
// Fails one workspace request per arm, so a resubmission can succeed.
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
import { rm, stat } from "node:fs/promises";
|
||||
export default function contribute(server) {
|
||||
return server.before("workspace.create", async ({ request }) => {
|
||||
const failure = ${JSON.stringify(failure)};
|
||||
if (!(await stat(failure).catch(() => null))) return request;
|
||||
await rm(failure, { force: true });
|
||||
throw new Error("Provisioning outcome is uncertain for test");
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
await provide({ arm: () => writeFile(failure, "fail") });
|
||||
await provide({
|
||||
release: () => rm(gate, { force: true }),
|
||||
// Replacing the command must not expose a truncated, empty file to the hook.
|
||||
fail: () => rename(failure, gate),
|
||||
});
|
||||
} finally {
|
||||
await rm(gate, { force: true });
|
||||
await client.removePlugin("lifecycle-logger");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await plugin.cleanup();
|
||||
@@ -182,14 +114,6 @@ export default function contribute(server) {
|
||||
gate.restore();
|
||||
}
|
||||
},
|
||||
workspacePromptRetry: async ({ page }, provide) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
try {
|
||||
await provide(createPromptRetryScenario(page, gate, "workspace"));
|
||||
} finally {
|
||||
gate.restore();
|
||||
}
|
||||
},
|
||||
agentRetries: async ({ page }, provide) => {
|
||||
await provide(await retryNextAgentCreation(page));
|
||||
},
|
||||
|
||||
@@ -338,11 +338,32 @@ 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,
|
||||
daemonPort?: number,
|
||||
options: OlderTimelinePagesOptions = {},
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
const { daemonPort } = options;
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
let repeatedEntryCount = 0;
|
||||
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
server.onMessage((received) => {
|
||||
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { awaitAssistantMessage } from "./agent-stream";
|
||||
import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork";
|
||||
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { attachComposerTestImage, fillComposerDraft } from "./composer";
|
||||
import { fillComposerDraft } from "./composer";
|
||||
import { createAgentTabFromMenu } from "./workspace-tabs";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
startingRefRow,
|
||||
selectWorkspaceIsolation,
|
||||
loadSessionMessageReaders,
|
||||
} from "./new-workspace";
|
||||
@@ -49,43 +44,6 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block:
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
/** Hold the creation request before the daemon can reserve or provision anything. */
|
||||
/** Holds each workspace request with an agent until `release` sends the held requests. */
|
||||
export async function holdWorkspaceProvisioning(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
let held: Array<() => void> = [];
|
||||
const requests: WorkspaceCreateRequest[] = [];
|
||||
let agentPrompts = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
const message = frames.client(frame);
|
||||
if (message?.type === "send_agent_message_request") agentPrompts++;
|
||||
if (message?.type === "workspace.create.request" && message.agent) {
|
||||
held.push(() => server.send(frame));
|
||||
requests.push(message);
|
||||
return;
|
||||
}
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
});
|
||||
async function waitForRequests(count: number) {
|
||||
await expect.poll(() => requests.length).toBe(count);
|
||||
return requests[count - 1]!;
|
||||
}
|
||||
return {
|
||||
waitForRequest: () => waitForRequests(1),
|
||||
waitForRequests,
|
||||
release: () => {
|
||||
const sending = held;
|
||||
held = [];
|
||||
for (const send of sending) send();
|
||||
},
|
||||
agentPrompts: () => agentPrompts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
|
||||
const create = page.getByRole("button", { name, exact: true });
|
||||
await expect(create).toBeEnabled();
|
||||
@@ -101,11 +59,9 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string):
|
||||
export async function observeCreationRequests(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
const pending = new Set<string>();
|
||||
let workspaceRequests = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = frames.client(payload);
|
||||
if (request?.type === "workspace.create.request") workspaceRequests++;
|
||||
if (
|
||||
request?.type === "workspace.create.request" ||
|
||||
request?.type === "agent.create.request"
|
||||
@@ -128,7 +84,6 @@ export async function observeCreationRequests(page: Page) {
|
||||
});
|
||||
});
|
||||
return {
|
||||
workspaceRequests: () => workspaceRequests,
|
||||
async settled() {
|
||||
await expect.poll(() => pending.size).toBe(0);
|
||||
},
|
||||
@@ -183,101 +138,18 @@ export async function retryNextAgentCreation(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
function agentComposer(page: Page) {
|
||||
// Retained workspaces keep hidden composers mounted beside the focused one.
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "creation-idempotency-",
|
||||
repo: {
|
||||
files: [{ path: "nested/context.txt", content: "Nested agent working directory" }],
|
||||
branches: ["recovery-base"],
|
||||
},
|
||||
});
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
const sourceClients: Array<{ close(): Promise<void> }> = [];
|
||||
return {
|
||||
cleanup: async () => {
|
||||
for (const client of sourceClients) await client.close();
|
||||
await project.cleanup();
|
||||
},
|
||||
async openNestedAgentFork(isolation: "local" | "worktree") {
|
||||
const cwd = `${project.repoPath}/nested`;
|
||||
const sourceClient = await connectNewWorkspaceDaemonClient();
|
||||
sourceClients.push(sourceClient);
|
||||
const source = await sourceClient.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
agent: {
|
||||
config: { provider: "mock", model: "e2e-fast-stream", modeId: "load-test", cwd },
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for the source conversation.",
|
||||
},
|
||||
});
|
||||
if (!source.agent || !source.workspace)
|
||||
throw new Error(source.error ?? "Missing nested source agent");
|
||||
evictionWorkspaceIds.add(source.workspace.id);
|
||||
await project.client.waitForFinish(source.agent.id, 20_000);
|
||||
await gotoWorkspace(page, source.workspace.id);
|
||||
await awaitAssistantMessage(page);
|
||||
await forkMostRecentAssistantTurnToNewWorkspace(page);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
return cwd;
|
||||
},
|
||||
attachImage: () => attachComposerTestImage(page),
|
||||
async expectImageVisible() {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Open image attachment", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectInitialPromptOnce(prompt: string) {
|
||||
const agents = (await project.client.fetchAgents()).entries.filter(
|
||||
({ agent }) => agent.workspaceId === workspaceId,
|
||||
);
|
||||
expect(agents).toHaveLength(1);
|
||||
const reader = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
const timeline = await reader.fetchAgentTimeline(agents[0]!.agent.id);
|
||||
expect(timeline.entries.filter(({ item }) => item.type === "user_message")).toHaveLength(1);
|
||||
expect(
|
||||
timeline.entries.filter(
|
||||
({ item }) => item.type === "user_message" && item.text === prompt,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
async expectNestedAgentCwd() {
|
||||
const workspace = (await project.client.fetchWorkspaces()).entries.find(
|
||||
(entry) => entry.id === workspaceId,
|
||||
)!;
|
||||
const nestedAgent = (await project.client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.workspaceId === workspaceId,
|
||||
)!.agent;
|
||||
expect(nestedAgent.cwd).toBe(`${workspace.workspaceDirectory}/nested`);
|
||||
},
|
||||
async closePendingAgent(agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).hover();
|
||||
await page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
},
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
},
|
||||
async selectWorktreeBranch(name: string) {
|
||||
await openStartingRefPicker(page);
|
||||
await startingRefRow(page, `${name}, local branch`).click();
|
||||
},
|
||||
async openAgentDraft() {
|
||||
await gotoWorkspace(page, project.workspaceId);
|
||||
await createAgentTabFromMenu(page);
|
||||
@@ -293,74 +165,6 @@ export async function createCreationScenario(page: Page) {
|
||||
if (prompt) await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, button);
|
||||
},
|
||||
async expectNormalAgentComposer() {
|
||||
await expect(agentComposer(page)).toBeEditable();
|
||||
// Provisioning belongs to the workspace header and sidebar, not the conversation.
|
||||
await expect(page.getByText("Creating workspace and agent…", { exact: true })).toHaveCount(0);
|
||||
},
|
||||
async expectAgentCannotBeStoppedYet() {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Stop agent" }).filter({ visible: true }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
async queueFollowUp(prompt: string) {
|
||||
await agentComposer(page).fill(prompt);
|
||||
await agentComposer(page).press("Enter");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
},
|
||||
async expectQueuedFollowUp(prompt: string) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
},
|
||||
async resubmitWorkspace() {
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
},
|
||||
async expectAgentConfig(config: { model: string; modeId: string }) {
|
||||
const agent = (await project.client.fetchAgents()).entries.find(
|
||||
(entry) => entry.agent.workspaceId === workspaceId,
|
||||
)!.agent;
|
||||
expect({ model: agent.model, modeId: agent.currentModeId }).toEqual(config);
|
||||
},
|
||||
async typeComposerDraft(text: string) {
|
||||
await agentComposer(page).fill(text);
|
||||
// The composer publishes typed text to its draft after the next paint.
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))),
|
||||
);
|
||||
},
|
||||
async expectComposerDraft(text: string) {
|
||||
await expect(agentComposer(page)).toHaveValue(text);
|
||||
},
|
||||
async expectAgentNotWorking() {
|
||||
await expect(
|
||||
page.getByTestId("turn-working-indicator").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
},
|
||||
async expectTimelineSettled() {
|
||||
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
|
||||
},
|
||||
async expectUserMessagesInOrder(prompts: string[]) {
|
||||
const agent = (await project.client.fetchAgents()).entries.find(
|
||||
(entry) => entry.agent.workspaceId === workspaceId,
|
||||
);
|
||||
const reader = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await reader.fetchAgentTimeline(agent!.agent.id)).entries.flatMap(({ item }) =>
|
||||
item.type === "user_message" ? [item.text] : [],
|
||||
),
|
||||
)
|
||||
.toEqual(prompts);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
async expectPromptVisible(prompt?: string) {
|
||||
// Inactive agent tabs retain their timeline DOM beside the visible draft.
|
||||
const rows = page.getByTestId("user-message").filter({ visible: true });
|
||||
@@ -371,7 +175,7 @@ export async function createCreationScenario(page: Page) {
|
||||
await expect(page.getByTestId("user-message").first()).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
|
||||
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
},
|
||||
@@ -420,57 +224,15 @@ export async function createCreationScenario(page: Page) {
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectFailedAgentDraft(prompt: string) {
|
||||
await requests.settled();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByText(/Creation startup failed for test/)
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(agentComposer(page)).toHaveValue(prompt);
|
||||
await this.expectNoCreationRecovery();
|
||||
},
|
||||
async resendFailedDraft() {
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
},
|
||||
async expectReturnedToNewWorkspace(prompt: string, error: string) {
|
||||
await requests.settled();
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
await expect(agentComposer(page)).toHaveValue(prompt);
|
||||
await expect(page.getByText(error).filter({ visible: true }).first()).toBeVisible();
|
||||
await this.expectNoCreationRecovery();
|
||||
expect(requests.workspaceRequests()).toBe(1);
|
||||
expect(
|
||||
(await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
async expectNoCreationRecovery() {
|
||||
await expect(page.getByRole("button", { name: "Retry creation", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Return to draft", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
async expectStartupFailure() {
|
||||
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
|
||||
},
|
||||
async expectOneCreatedWorkspace() {
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -503,11 +265,8 @@ export async function createCreationScenario(page: Page) {
|
||||
.toBe(title);
|
||||
},
|
||||
async evictAndReturnToDraft() {
|
||||
workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!;
|
||||
const draft = page
|
||||
.locator(
|
||||
'[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]',
|
||||
)
|
||||
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const draftTestId = await draft.getAttribute("data-testid");
|
||||
@@ -517,7 +276,6 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
@@ -538,18 +296,15 @@ export async function createCreationScenario(page: Page) {
|
||||
export function createPromptRetryScenario(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
kind: "agent" | "workspace" = "agent",
|
||||
) {
|
||||
const requestType = `${kind}.create.request`;
|
||||
const responseType = `${kind}.create.response`;
|
||||
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
|
||||
return {
|
||||
holdAcknowledgement() {
|
||||
gate.holdNextServerMessage(responseType);
|
||||
gate.holdNextServerMessage("agent.create.response");
|
||||
},
|
||||
async waitForDeliveredPrompt() {
|
||||
await gate.waitForHeldServerMessage(responseType);
|
||||
firstMessage = gate.getClientRequests(requestType).at(-1);
|
||||
await gate.waitForHeldServerMessage("agent.create.response");
|
||||
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
|
||||
},
|
||||
async disconnectAndReconnect() {
|
||||
const fetches = gate.getClientRequestCount("fetch_agents_request");
|
||||
@@ -560,89 +315,13 @@ export function createPromptRetryScenario(
|
||||
.toBeGreaterThan(fetches);
|
||||
},
|
||||
async expectSameAgentAndMessage() {
|
||||
expect(gate.getClientRequestCount(requestType)).toBe(1);
|
||||
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
|
||||
.toBeGreaterThan(0);
|
||||
expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage);
|
||||
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function hideCreationLifecycle(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
let requests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (frames.client(frame)?.type === "workspace.create.request") requests++;
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
const message = frames.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "server_info" &&
|
||||
typeof message.payload.features === "object" &&
|
||||
message.payload.features !== null
|
||||
) {
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
features: { ...message.payload.features, creationLifecycle: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return { expectNoCreationRequest: () => expect(requests).toBe(0) };
|
||||
}
|
||||
|
||||
/** Simulate an uncertain receipt after the real daemon registered and ran the initial agent. */
|
||||
export async function reportUnknownPromptOutcome(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => server.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = frames.server(frame);
|
||||
if (
|
||||
message?.type === "workspace.create.response" &&
|
||||
message.payload.agent &&
|
||||
message.payload.creation
|
||||
) {
|
||||
const error = "Initial prompt outcome is uncertain for test";
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
...JSON.parse(frame.toString()),
|
||||
message: {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
error,
|
||||
creation: {
|
||||
...message.payload.creation,
|
||||
phase: "failed",
|
||||
error,
|
||||
outcomeUnknown: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ 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> {
|
||||
|
||||
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgents"
|
||||
| "fetchAgentTimeline"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
declare global {
|
||||
var __PASEO_E2E_REACT_RENDERS__: Record<string, number> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts which components render in each React commit, by name, the way React DevTools
|
||||
* highlights updates: a fiber rendered when React performed work on it in that commit.
|
||||
* Install before the app loads; React registers with the DevTools hook at startup.
|
||||
*/
|
||||
export async function recordReactRenders(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const PERFORMED_WORK = 1;
|
||||
interface Fiber {
|
||||
type: unknown;
|
||||
flags: number;
|
||||
child: Fiber | null;
|
||||
sibling: Fiber | null;
|
||||
alternate: Fiber | null;
|
||||
}
|
||||
const nameOf = (type: unknown): string | null => {
|
||||
if (typeof type === "function") {
|
||||
return (type as { displayName?: string }).displayName || type.name || null;
|
||||
}
|
||||
if (!type || typeof type !== "object") return null;
|
||||
const component = type as { displayName?: string; render?: unknown; type?: unknown };
|
||||
return component.displayName || nameOf(component.render) || nameOf(component.type);
|
||||
};
|
||||
const record = (root: Fiber) => {
|
||||
const renders = (globalThis.__PASEO_E2E_REACT_RENDERS__ ??= {});
|
||||
const pending: Fiber[] = [root];
|
||||
while (pending.length > 0) {
|
||||
const fiber = pending.pop()!;
|
||||
const name = typeof fiber.type === "string" ? null : nameOf(fiber.type);
|
||||
if (name && (fiber.flags & PERFORMED_WORK) !== 0) renders[name] = (renders[name] ?? 0) + 1;
|
||||
// A subtree React bailed out of is shared with the previous commit; skip it.
|
||||
if (fiber.alternate && fiber.child === fiber.alternate.child) continue;
|
||||
for (let child = fiber.child; child; child = child.sibling) pending.push(child);
|
||||
}
|
||||
};
|
||||
let rendererCount = 0;
|
||||
Object.defineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
|
||||
configurable: true,
|
||||
value: {
|
||||
supportsFiber: true,
|
||||
isDisabled: false,
|
||||
renderers: new Map(),
|
||||
inject(renderer: unknown) {
|
||||
rendererCount += 1;
|
||||
this.renderers.set(rendererCount, renderer);
|
||||
return rendererCount;
|
||||
},
|
||||
checkDCE() {},
|
||||
onScheduleFiberRoot() {},
|
||||
onCommitFiberRoot(_rendererId: number, root: { current: Fiber }) {
|
||||
record(root.current);
|
||||
},
|
||||
onPostCommitFiberRoot() {},
|
||||
onCommitFiberUnmount() {},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetReactRenders(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_E2E_REACT_RENDERS__ = {};
|
||||
});
|
||||
}
|
||||
|
||||
/** Render counts per component name since the last reset, read after the next painted frame. */
|
||||
export async function readReactRenders(page: Page): Promise<Record<string, number>> {
|
||||
return page.evaluate(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ };
|
||||
});
|
||||
}
|
||||
@@ -149,7 +149,11 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -5,7 +5,7 @@ export function sheet(page: Page, label: string): Locator {
|
||||
return page.getByRole("dialog", { name: label });
|
||||
}
|
||||
|
||||
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
|
||||
/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */
|
||||
export function sheetHeader(page: Page, label: string): Locator {
|
||||
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
|
||||
}
|
||||
@@ -48,6 +48,12 @@ export async function closeSheet(page: Page, label: string): Promise<void> {
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Steps a sheet opened from a list back to that list, in the same sheet. */
|
||||
export async function backToList(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Back" }).click();
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
@@ -76,6 +82,15 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Each open sheet renders its own backdrop, so counting them counts the sheets. */
|
||||
export async function expectOneSheetOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectNoSheetOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
|
||||
@@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
@@ -151,10 +151,21 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||
/**
|
||||
* On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the
|
||||
* pane whose track to open, for when a subagent's sheet shows a track of its own.
|
||||
*/
|
||||
export async function openSubagentFromCompactTrack(
|
||||
page: Page,
|
||||
childId: string,
|
||||
track: Page | Locator = page,
|
||||
): Promise<void> {
|
||||
await track.getByTestId("subagents-track-header").click();
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
// Tap the row once the sheet has brought it on screen, as a finger would. Clicking it while it is
|
||||
// still below the fold makes Playwright scroll the sheet host, and every sheet then docks short.
|
||||
await expect(row).toBeInViewport();
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type TestInfo, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
type OlderTimelinePagesOptions,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
@@ -35,6 +36,19 @@ 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;
|
||||
@@ -126,6 +140,93 @@ 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,
|
||||
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
daemonPort?: number,
|
||||
options?: OlderTimelinePagesOptions,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||
page,
|
||||
agent.agentId,
|
||||
daemonPort,
|
||||
options,
|
||||
);
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
// 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 });
|
||||
},
|
||||
expectNoRepeatedEntries() {
|
||||
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||
@@ -423,6 +535,53 @@ 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)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { event?: { item?: { status?: string } } } };
|
||||
};
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
if (!item) {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.status = status;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let secondCallId: string | null = null;
|
||||
let secondRunningMessage: WebSocketMessage | null = null;
|
||||
let pauseAfterSecondRunning = false;
|
||||
let releaseSecondRequested = false;
|
||||
let forwardSecondRunning: (() => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
let resolveSecondRunning!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
const secondRunning = new Promise<void>((resolve) => {
|
||||
resolveSecondRunning = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (secondRunningMessage) {
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningMessage = null;
|
||||
}
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseAfterSecondRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
if (!toolCall) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
ws.send(message);
|
||||
if (toolCall.status === "completed") {
|
||||
resolveFirstCompleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (toolCall.callId === secondCallId) {
|
||||
const transformedMessage =
|
||||
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
|
||||
secondRunningMessage = transformedMessage;
|
||||
pauseAfterSecondRunning = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
forwardSecondRunning?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,47 @@
|
||||
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"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -61,7 +61,6 @@
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native/normalize-colors": "0.86.3",
|
||||
@@ -77,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.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAgentCreation } from "@/workspace-creation";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
@@ -34,7 +33,6 @@ export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const creation = useAgentCreation(context.serverId, target.agentId);
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
@@ -51,10 +49,8 @@ export function useAgentDescriptor(
|
||||
};
|
||||
}),
|
||||
);
|
||||
const provider = creation ? creation.provider : descriptorState.provider;
|
||||
const title = creation ? creation.text : descriptorState.title;
|
||||
const icon = useProviderIcon(provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, provider, title, icon });
|
||||
const icon = useProviderIcon(descriptorState.provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, icon });
|
||||
}
|
||||
|
||||
export function buildAgentDescriptor(input: {
|
||||
|
||||
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
|
||||
isTurnActive: input.isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||
liveHead: [],
|
||||
timingByAssistantId: timingFor(assistant.id),
|
||||
timingByItemId: timingFor(assistant.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
|
||||
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
||||
|
||||
@@ -32,7 +33,7 @@ export function findMountedWindowStart(input: {
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
|
||||
@@ -104,7 +104,7 @@ function layoutFor(input: {
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
timingByItemId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("places one response footer after an adjacent tagged tool-only turn", () => {
|
||||
it("keeps separate footers for an assistant turn and an adjacent 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(priorAssistant.id);
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
|
||||
});
|
||||
|
||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
timingByItemId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
timingByItemId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
|
||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
||||
isTurnActive: boolean;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
interface TurnFooterSource {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
@@ -67,23 +67,26 @@ function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
timing: input.timingByItemId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantInResponse(input: {
|
||||
function findTurnFooterSource(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
}): TurnFooterSource | null {
|
||||
const last = input.items[input.startIndex];
|
||||
const toolFallback: TurnFooterSource | null =
|
||||
last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null;
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
@@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: {
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||
return null;
|
||||
return toolFallback;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
@@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: {
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
return toolFallback;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
const source = findTurnFooterSource({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!assistant) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByItemId: input.timingByItemId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
const source = findTurnFooterSource({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByItemId: input.timingByItemId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +309,7 @@ function layoutSegmentItem(
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByItemId: input.timingByItemId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
@@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByItemId: input.timingByItemId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
@@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByItemId: input.timingByItemId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
|
||||
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
historyStart: 2,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
||||
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
|
||||
expect(model.turnTiming.byItemId.has("live-a")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps completed turn timing to assistant ids across committed history and live head", () => {
|
||||
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
expect(model.turnTiming.byItemId.get("live-a")).toEqual({
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
expect(model.turnTiming.byItemId.get("a1")).toEqual({
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
expect(model.turnTiming.byItemId.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["web", "native"] as const)(
|
||||
@@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(second.history).toBe(first.history);
|
||||
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
|
||||
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
|
||||
expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId);
|
||||
expect(first.history).toHaveLength(20);
|
||||
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
|
||||
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byItemId.get("a38")).toEqual({
|
||||
completedAt: tail[77]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -588,3 +589,197 @@ describe("timeline presentation", () => {
|
||||
expect(projectTimelineItems([source], () => [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("opening a turn reveals individual calls rather than another tool group", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
toolCall("two", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
const result = present({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: false,
|
||||
expandedTurnHostIds: new Set(["prompt"]),
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
"preserves the reader when a prepend extends a group in %s",
|
||||
(display) => {
|
||||
const present = createStreamPresentation();
|
||||
const anchor = createReadingAnchor();
|
||||
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
display,
|
||||
head: [],
|
||||
isTurnActive: false,
|
||||
};
|
||||
const partial = present({ ...input, tail: calls.slice(1) });
|
||||
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
|
||||
const completed = present({ ...input, tail: calls });
|
||||
const groupId = completed.tail[0]!.id;
|
||||
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
0,
|
||||
[{ id: groupId, top: 448, height: 200 }],
|
||||
false,
|
||||
completed.readingRowReplacements,
|
||||
),
|
||||
).toBe(400);
|
||||
expect(anchor.getRowId()).toBe(groupId);
|
||||
const timestamp = new Date();
|
||||
const folded = present({
|
||||
...input,
|
||||
display: "collapseTurns",
|
||||
tail: [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
...calls,
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
|
||||
const call = toolCall("received", "agent_message");
|
||||
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
call.turnId = "turn";
|
||||
call.payload.data.agentMessage = {
|
||||
event: "message",
|
||||
sender: { id: "sender", title: "Reviewer" },
|
||||
text: "Ready",
|
||||
};
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail: [call],
|
||||
head: [],
|
||||
});
|
||||
expect(result.tail[0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
id: call.id,
|
||||
turnId: "turn",
|
||||
text: "Ready",
|
||||
sender: { id: "sender", title: "Reviewer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reopen the previous turn while a new run waits for its first row", () => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
for (const item of tail) item.turnId = "completed";
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: true,
|
||||
activeTurnId: "new",
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
// the completed one; it must stay folded until the new turn's rows arrive.
|
||||
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
for (const item of tail) item.turnId = "completed";
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "thought",
|
||||
text: "Consider this",
|
||||
status: "ready",
|
||||
timestamp,
|
||||
};
|
||||
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
|
||||
for (const item of tail) item.turnId = "run";
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseToolCalls" as const,
|
||||
isTurnActive: false,
|
||||
};
|
||||
const grouped = present({ ...input, autoExpandReasoning: false });
|
||||
expect(grouped.tail).toHaveLength(1);
|
||||
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
|
||||
const separate = present({ ...input, autoExpandReasoning: true });
|
||||
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
|
||||
expect(separate.groupsByHostId.has("thought")).toBe(false);
|
||||
const next = { ...thought, id: "next", turnId: "next-run" };
|
||||
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
|
||||
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"reveals raw reasoning when opening a turn (Expand thinking %s)",
|
||||
(autoExpandReasoning) => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns" as const,
|
||||
isTurnActive: false,
|
||||
autoExpandReasoning,
|
||||
};
|
||||
const present = createStreamPresentation();
|
||||
const closed = present(input);
|
||||
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
|
||||
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
|
||||
expect(opened.tail).toEqual(tail);
|
||||
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
|
||||
expect(opened.groupsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isTurnBoundary } from "./turn-membership";
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
@@ -10,19 +11,38 @@ import {
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
transform: TimelineItemTransform | undefined;
|
||||
display: ChatDisplay;
|
||||
autoExpandReasoning?: boolean;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
isTurnActive: boolean;
|
||||
activeTurnId?: string | null;
|
||||
}
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
readingRowReplacements: ReadingRowReplacements;
|
||||
}
|
||||
|
||||
function groupedRowHosts(
|
||||
rows: readonly StreamItem[],
|
||||
groups: ToolCallDetailProjection["groupsByHostId"],
|
||||
): ReadonlyMap<string, string> {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const group = groups.get(row.id);
|
||||
if (!group) continue;
|
||||
for (const call of group.run.calls) {
|
||||
if (call.id !== row.id) hosts.set(call.id, row.id);
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
@@ -55,27 +75,54 @@ export function createStreamPresentation() {
|
||||
let historyTransform: TimelineItemTransform | undefined;
|
||||
let historyRows: StreamItem[] = [];
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedAutoExpandReasoning: boolean | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
let collapsed: TurnCollapse | undefined;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
if (item.kind === "tool_call" && item.payload.source === "agent") {
|
||||
const message = item.payload.data.agentMessage;
|
||||
if (message?.event === "message") {
|
||||
return [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: item.id,
|
||||
turnId: item.turnId,
|
||||
timelineCursor: item.timelineCursor,
|
||||
timestamp: item.timestamp,
|
||||
text: message.text,
|
||||
sender: message.sender,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
): Omit<StreamPresentation, "readingRowReplacements"> {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
||||
const last = projection.tail.at(-1) ?? null;
|
||||
const first = projection.head[0] ?? null;
|
||||
const activeTurnIsAnother = Boolean(
|
||||
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
|
||||
);
|
||||
// Without head rows, the latest retained turn is live only while the active run is that
|
||||
// turn. A run that has not opened yet carries no id and belongs to the next turn.
|
||||
const isLatestTurnOpen = first
|
||||
? !isTurnBoundary(last, first) && !activeTurnIsAnother
|
||||
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
@@ -93,7 +140,20 @@ export function createStreamPresentation() {
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
return { ...projection, tail, turns };
|
||||
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
|
||||
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
|
||||
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
|
||||
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
|
||||
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
|
||||
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
|
||||
});
|
||||
return {
|
||||
...projection,
|
||||
tail,
|
||||
turns,
|
||||
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
|
||||
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
|
||||
};
|
||||
}
|
||||
|
||||
return (input: PresentationInput): StreamPresentation => {
|
||||
@@ -111,20 +171,48 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
if (
|
||||
preparedTail !== historyRows ||
|
||||
preparedLevel !== level ||
|
||||
preparedAutoExpandReasoning !== input.autoExpandReasoning
|
||||
) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
|
||||
historyGroupHosts = preparedHistory
|
||||
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
|
||||
: new Map();
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
const projection = projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
const result = collapse(projection, input);
|
||||
// Only the trailing retained group can absorb head rows. Reuse the retained
|
||||
// index across streaming updates rather than walking the full history again.
|
||||
const last = projection.tail.at(-1);
|
||||
const liveGroupHosts = groupedRowHosts(
|
||||
last ? [last, ...projection.head] : projection.head,
|
||||
projection.groupsByHostId,
|
||||
);
|
||||
const retainedGroupHosts = historyGroupHosts;
|
||||
return {
|
||||
...result,
|
||||
readingRowReplacements: {
|
||||
get(id) {
|
||||
if (result.turns.nestedItemIds.has(id)) return undefined;
|
||||
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
|
||||
return (
|
||||
result.turns.hostIdByHiddenRowId.get(id) ??
|
||||
result.turns.hostIdByHiddenRowId.get(groupHost) ??
|
||||
(groupHost === id ? undefined : groupHost)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
describe("reading anchor", () => {
|
||||
it("pins the folded turn host when a prepend replaces the row being read", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
expect(
|
||||
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
expect(anchor.getRowId()).toBe("prompt");
|
||||
expect(
|
||||
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{ id: "above", top: 0, height: 500 },
|
||||
{ id: "reading", top: 500, height: 500 },
|
||||
|
||||
@@ -6,6 +6,10 @@ interface RowGeometry {
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
get(id: string): string | undefined;
|
||||
}
|
||||
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
const getRowId = (replacements = NO_REPLACEMENTS) =>
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
rows: readonly RowGeometry[],
|
||||
userScrolled = false,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
|
||||
// The first virtualized commit can precede its mounted range. Keep the
|
||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
});
|
||||
|
||||
it("collects copy content across adjacent turns without a visible prompt", () => {
|
||||
it("keeps copied content within the canonical turn without a visible prompt", () => {
|
||||
const chronological: StreamItem[] = [
|
||||
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
||||
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
||||
@@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
items: chronological,
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBe("first turn\n\nsecond turn");
|
||||
).toBe("second turn");
|
||||
});
|
||||
|
||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
@@ -131,6 +131,79 @@ describe("createWebStreamStrategy", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(["liveHead", "historyMounted"] as const)(
|
||||
"keeps one rendered tool row after late prior-turn updates in %s",
|
||||
(lane) => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: lane === "historyMounted",
|
||||
hasLiveHead: lane === "liveHead",
|
||||
},
|
||||
renderers: createRenderers(() => {}),
|
||||
listEmptyComponent: null,
|
||||
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
let items: StreamItem[] = [];
|
||||
const updates = [
|
||||
{ callId: "background", turnId: "old", status: "running" },
|
||||
{ callId: "exec", turnId: "current", status: "running" },
|
||||
{ callId: "background", turnId: "old", status: "completed" },
|
||||
{ callId: "exec", turnId: "current", status: "completed" },
|
||||
] as const;
|
||||
for (const [index, update] of updates.entries()) {
|
||||
items = reduceStreamUpdate(
|
||||
items,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: update.turnId,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: update.callId,
|
||||
name: "exec_command",
|
||||
status: update.status,
|
||||
error: null,
|
||||
detail: { type: "shell", command: update.callId },
|
||||
},
|
||||
},
|
||||
new Date(index),
|
||||
);
|
||||
act(() =>
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...input,
|
||||
segments: { ...input.segments, [lane]: items },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = [...container.querySelectorAll("[data-history-row-id]")];
|
||||
expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([
|
||||
"agent_tool_turn:old/background",
|
||||
"agent_tool_turn:current/exec",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("mounts virtualized history without recursive row measurement updates", () => {
|
||||
const rowRenderCount = vi.fn();
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
|
||||
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
@@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||
const correctedOffset =
|
||||
container && measurement
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
? readingAnchor.project(
|
||||
container.scrollTop,
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
@@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
@@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
const correctedTop = readingAnchor.reconcile(
|
||||
container.scrollTop,
|
||||
rows,
|
||||
false,
|
||||
props.readingRowReplacements,
|
||||
);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
import { continuesResponse } from "./turn-membership";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
@@ -78,6 +79,8 @@ export interface StreamRenderInput {
|
||||
segments: StreamRenderSegments;
|
||||
historyRowRevision?: StreamHistoryRowRevision;
|
||||
liveHeadRowRevision?: unknown;
|
||||
/** Presentation rows replaced by another row after history is folded. */
|
||||
readingRowReplacements?: ReadingRowReplacements;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
|
||||
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
|
||||
isTurnActive: true,
|
||||
history: model.history,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,49 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
|
||||
});
|
||||
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
|
||||
const create = shell("create");
|
||||
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
create.payload.data.name = "mcp__paseo__create_agent";
|
||||
create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null };
|
||||
const finished = shell("finished");
|
||||
if (finished.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
finished.payload.data.name = "agent_notification";
|
||||
finished.payload.data.agentMessage = {
|
||||
event: "finished",
|
||||
sender: { id: "worker", title: "PR helper" },
|
||||
text: "Finished",
|
||||
};
|
||||
const items: StreamItem[] = [
|
||||
user("u1"),
|
||||
create,
|
||||
finished,
|
||||
{
|
||||
kind: "compaction",
|
||||
id: "compaction",
|
||||
timestamp: at,
|
||||
status: "completed",
|
||||
} satisfies StreamItem,
|
||||
shell("bash"),
|
||||
reply("done"),
|
||||
].map((item) => Object.assign(item, { turnId: "same-run" }));
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
|
||||
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
paseoActions: { "Created agent": 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
@@ -63,7 +106,6 @@ describe("collapseTurns", () => {
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
messageCount: 1,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
@@ -121,6 +163,34 @@ describe("collapseTurns", () => {
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a completed response folded when a promptless canonical turn starts", () => {
|
||||
const first = [user("u1"), shell("c1"), reply("r1")].map((item) =>
|
||||
Object.assign(item, { turnId: "first" }),
|
||||
);
|
||||
const next = { ...shell("heartbeat"), turnId: "second" };
|
||||
const result = collapse([...first, next], { isLatestTurnOpen: true });
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
|
||||
expect(result.turnsByHostId.has("u1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps mid-turn messages visible when work is folded", () => {
|
||||
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
|
||||
for (const item of tail) item.turnId = "same-turn";
|
||||
const result = collapse(tail);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
|
||||
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
|
||||
});
|
||||
|
||||
it("reveals every raw call inside an expanded turn", () => {
|
||||
const calls = [shell("one"), shell("two")];
|
||||
const result = collapse([user("prompt"), calls[0]!, reply("done")], {
|
||||
expandedHostIds: new Set(["prompt"]),
|
||||
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
|
||||
});
|
||||
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
it("counts every call a tool-call group row stands for", () => {
|
||||
const group = [shell("c1"), shell("c2"), shell("c3")];
|
||||
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
|
||||
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
|
||||
import {
|
||||
getStreamItemMessageId,
|
||||
type StreamItem,
|
||||
type ThoughtItem,
|
||||
type ToolCallItem,
|
||||
} from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
messageCount: number;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
||||
/** The input or first loaded row that carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
@@ -20,6 +25,8 @@ export interface TurnCollapse {
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -28,7 +35,7 @@ export interface CollapseTurnsInput {
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
||||
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
@@ -39,17 +46,21 @@ function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: ToolCallItem[] = [];
|
||||
let messageCount = 0;
|
||||
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "assistant_message") messageCount += 1;
|
||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
||||
return { tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
|
||||
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||
return isTurnTrigger(item) || item.kind === "compaction";
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
@@ -63,7 +74,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number):
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Thoughts fold with the turn but are not counted.
|
||||
* before its reply. Reasoning is revealed only when the turn opens.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
@@ -71,34 +82,55 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
|
||||
if (
|
||||
!host ||
|
||||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
|
||||
!isClosed ||
|
||||
replyIndex <= runStart
|
||||
) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary)) {
|
||||
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
for (const item of folded) {
|
||||
const revealed = expanded
|
||||
? folded.flatMap<StreamItem>((item) =>
|
||||
item.kind === "tool_call" || item.kind === "thought"
|
||||
? [...(input.getGroupedCalls(item.id) ?? [item])]
|
||||
: [item],
|
||||
)
|
||||
: folded;
|
||||
for (const item of revealed) {
|
||||
if (staysVisibleWhenCollapsed(item)) continue;
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
if (expanded) output.push(...folded);
|
||||
output.push(
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
};
|
||||
|
||||
for (let index = 0; index < tail.length; index += 1) {
|
||||
if (tail[index]?.kind !== "user_message") continue;
|
||||
if (
|
||||
!(index === 0 && isTurnTrigger(tail[index]!)) &&
|
||||
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
|
||||
)
|
||||
continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
@@ -111,12 +143,17 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output.length === tail.length ? tail : output,
|
||||
tail:
|
||||
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||
? tail
|
||||
: output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
@@ -17,19 +17,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { messageCount, tools } = turn.summary;
|
||||
const messageParts = useMemo(
|
||||
() =>
|
||||
messageCount > 0
|
||||
? [
|
||||
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
|
||||
count: messageCount,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
[messageCount, t],
|
||||
);
|
||||
const label = useOverviewSummaryLabel(tools, messageParts);
|
||||
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||
const toggle = useCallback(
|
||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||
[onExpandedChange, turn.expanded, turn.hostId],
|
||||
|
||||
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||
|
||||
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
||||
isTurnActive,
|
||||
history: model.segments.historyMounted,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -67,6 +67,24 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): str
|
||||
}
|
||||
|
||||
describe("canonical turn membership", () => {
|
||||
it("uses trigger fallback for missing IDs without pretending to recover batching", () => {
|
||||
const previous = assistant("reply", 1, "first");
|
||||
const trigger = runningTool("heartbeat", 2, "first");
|
||||
if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent")
|
||||
throw new Error("Expected agent tool");
|
||||
trigger.payload.data.name = "heartbeat";
|
||||
expect(continuesTurn(previous, trigger)).toBe(true);
|
||||
delete trigger.turnId;
|
||||
expect(continuesTurn(previous, trigger)).toBe(false);
|
||||
trigger.payload.data.name = "agent_message";
|
||||
trigger.payload.data.agentMessage = {
|
||||
event: "finished",
|
||||
sender: { id: "worker" },
|
||||
text: "Done",
|
||||
};
|
||||
expect(continuesTurn(previous, trigger)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops tagged traversal at every mismatching turn ID", () => {
|
||||
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
|
||||
false,
|
||||
@@ -76,7 +94,7 @@ describe("canonical turn membership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders adjacent turns without visible prompts as one response", () => {
|
||||
it("renders a separate footer for each canonical turn without visible prompts", () => {
|
||||
const items = [
|
||||
user("prompt", 1, "turn-1"),
|
||||
assistant("first reply", 2, "turn-1"),
|
||||
@@ -88,7 +106,11 @@ describe("canonical turn membership", () => {
|
||||
|
||||
const completed = layoutFor(items, false);
|
||||
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
||||
expect(completedFooterIds(completed.layout)).toEqual([
|
||||
"first reply",
|
||||
"heartbeat-1-reply",
|
||||
"heartbeat-2-reply",
|
||||
]);
|
||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||
completedAt: at(6),
|
||||
durationMs: null,
|
||||
@@ -123,7 +145,7 @@ describe("canonical turn membership", () => {
|
||||
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
/**
|
||||
* Canonical turn IDs take precedence. Timelines without them retain the legacy
|
||||
* user-message boundary rule so old daemons and persisted rows need no rewrite.
|
||||
* trigger boundary rule; missing IDs cannot reconstruct historical batching.
|
||||
*/
|
||||
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
if (previous.turnId !== undefined && next.turnId !== undefined) {
|
||||
return previous.turnId === next.turnId;
|
||||
}
|
||||
return next.kind !== "user_message";
|
||||
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
|
||||
}
|
||||
|
||||
/**
|
||||
* A visible response can span multiple canonical turns when their prompts are
|
||||
* system-injected and therefore absent from the Paseo timeline.
|
||||
* Every canonical turn owns its response, including system-triggered runs.
|
||||
*/
|
||||
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
return continuesTurn(previous, next) || next.kind !== "user_message";
|
||||
return continuesTurn(previous, next);
|
||||
}
|
||||
|
||||
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
@@ -37,3 +34,12 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
|
||||
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
|
||||
return turnId !== null && item.turnId === turnId;
|
||||
}
|
||||
|
||||
/** Inputs that start a turn when older history has no canonical turn IDs. */
|
||||
export function isTurnTrigger(item: StreamItem): boolean {
|
||||
return isTimelineTurnTrigger(
|
||||
item.kind === "tool_call" && item.payload.source === "agent"
|
||||
? { type: item.kind, ...item.payload.data }
|
||||
: { type: item.kind },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ import { returnToTimelineTail } from "./timeline-tail-navigation";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import {
|
||||
ToolCallExpansionContext,
|
||||
useAgentToolCallExpansion,
|
||||
useExpandedToolCallGroups,
|
||||
} from "@/tool-calls/expansion-context";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
@@ -90,6 +95,7 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -363,9 +369,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const toolCallExpansion = useAgentToolCallExpansion(agentId);
|
||||
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
@@ -430,7 +435,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
useEffect(() => {
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
@@ -459,6 +463,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -466,8 +471,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
@@ -524,8 +530,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
activeTurnId: effectiveTurnPresentation.turnId,
|
||||
}),
|
||||
[
|
||||
presentStream,
|
||||
@@ -533,8 +541,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
effectiveTurnPresentation.turnId,
|
||||
],
|
||||
);
|
||||
const {
|
||||
@@ -576,12 +586,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
timingByItemId: baseRenderModel.turnTiming.byItemId,
|
||||
}),
|
||||
[
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
baseRenderModel.turnTiming.byItemId,
|
||||
isTurnActive,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
@@ -701,17 +711,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(groupId);
|
||||
} else {
|
||||
next.delete(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
@@ -722,6 +722,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId={agentId}
|
||||
messageId={item.messageId}
|
||||
message={item.text}
|
||||
sender={item.sender}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
@@ -807,7 +808,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(
|
||||
item: Extract<StreamItem, { kind: "tool_call" }>,
|
||||
isLastInSequence: boolean,
|
||||
maxDetailHeight?: number,
|
||||
options: { maxDetailHeight?: number; standalone?: boolean } = {},
|
||||
) => {
|
||||
const { payload } = item;
|
||||
|
||||
@@ -839,7 +840,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
standalone={options.standalone}
|
||||
maxDetailHeight={options.maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -855,7 +857,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
standalone={options.standalone}
|
||||
maxDetailHeight={options.maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -868,12 +871,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
presentation.groupsByHostId.get(hostId),
|
||||
);
|
||||
const renderToolCallItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
(
|
||||
layoutItem: StreamLayoutItem,
|
||||
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
|
||||
) => {
|
||||
const group = getToolCallGroup(item.id);
|
||||
if (!group) {
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
return item.kind === "thought"
|
||||
? renderThoughtItem(layoutItem, item)
|
||||
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
||||
const expanded = expandedToolCallGroups.has(group.run.id);
|
||||
return (
|
||||
<OverviewToolCallGroupView
|
||||
group={group}
|
||||
@@ -884,10 +892,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{expanded
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
{call.kind === "thought" ? (
|
||||
// Reasoning is grouped only while Expand thinking is off, so an opened
|
||||
// group shows each thought as a closed row like any other call.
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded={false}
|
||||
standalone={false}
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
renderSingleToolCallItem(call, index === group.run.calls.length - 1, {
|
||||
maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
standalone: false,
|
||||
})
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
@@ -896,7 +917,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
},
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedToolCallGroups,
|
||||
renderThoughtItem,
|
||||
setInlineDetailsExpanded,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
@@ -914,10 +937,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderAssistantMessageItem(layoutItem, item);
|
||||
|
||||
case "thought":
|
||||
return renderThoughtItem(layoutItem, item);
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -947,8 +980,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderThoughtItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
);
|
||||
@@ -1135,15 +1169,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: {
|
||||
// An opened lone call also marks its future group open; that row stays unrevised
|
||||
// until a group actually forms there.
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedToolCallGroups,
|
||||
getToolCallGroup,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
@@ -1168,29 +1205,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<ToolCallSheetProvider>
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision: expandedToolCallGroupIds,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
olderHistoryProgressKey: progressKey,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision: expandedToolCallGroups,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
olderHistoryProgressKey: progressKey,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
</ToolCallExpansionContext.Provider>
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
prompts={chatOutline.prompts}
|
||||
@@ -1363,6 +1403,7 @@ interface ThoughtSlotProps {
|
||||
status: Extract<StreamItem, { kind: "thought" }>["status"];
|
||||
isLastInSequence: boolean;
|
||||
defaultExpanded: boolean;
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
|
||||
@@ -1373,6 +1414,7 @@ function ThoughtSlot({
|
||||
status,
|
||||
isLastInSequence,
|
||||
defaultExpanded,
|
||||
standalone,
|
||||
}: ThoughtSlotProps) {
|
||||
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
||||
return (
|
||||
@@ -1384,6 +1426,7 @@ function ThoughtSlot({
|
||||
status={status === "ready" ? "completed" : "executing"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
defaultExpanded={defaultExpanded}
|
||||
standalone={standalone}
|
||||
forceInline={defaultExpanded}
|
||||
/>
|
||||
);
|
||||
@@ -1398,7 +1441,9 @@ function ToolCallSlot({
|
||||
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
|
||||
[onInlineDetailsExpandedChangeByItemId, itemId],
|
||||
);
|
||||
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
|
||||
return (
|
||||
<ToolCall {...rest} itemId={itemId} onInlineDetailsExpandedChange={handleExpandedChange} />
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -1861,12 +1906,11 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper
|
||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||
switch (turnIndent) {
|
||||
case "none":
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
case "openedHost":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
{children}
|
||||
<IndentRail style={gapStyle} />
|
||||
{turnIndent === "openedHost" ? <IndentRail style={gapStyle} /> : null}
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
|
||||
@@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
||||
}
|
||||
|
||||
describe("findMountedWindowStart", () => {
|
||||
it("mounts complete autonomous runs and keeps same-run steers together", () => {
|
||||
const items = [
|
||||
{ ...userMessage("human", 1), turnId: "first" },
|
||||
{ ...assistantMessage("reply", 2), turnId: "first" },
|
||||
{ ...toolCall("wake", 3), turnId: "autonomous" },
|
||||
{ ...userMessage("steer", 4), turnId: "autonomous" },
|
||||
{ ...assistantMessage("done", 5), turnId: "autonomous" },
|
||||
];
|
||||
expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps all items mounted when the chat is below the threshold", () => {
|
||||
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,12 +14,23 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Text } from "react-native";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SheetBottomInset } from "./adaptive-modal-sheet";
|
||||
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const SCREEN_INSETS = { top: 24, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
function BottomInsetProbe() {
|
||||
return <Text testID="content-bottom-inset">{String(useSafeAreaInsets().bottom)}</Text>;
|
||||
}
|
||||
|
||||
describe("SheetBottomInset", () => {
|
||||
it("pads the bottom inset once and leaves none for its content", () => {
|
||||
render(
|
||||
<SafeAreaInsetsContext.Provider value={SCREEN_INSETS}>
|
||||
<SheetBottomInset testID="sheet-body">
|
||||
<BottomInsetProbe />
|
||||
</SheetBottomInset>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sheet-body").style.paddingBottom).toBe("34px");
|
||||
// Content that pads the inset itself, like the composer dock, must not pad it again.
|
||||
expect(screen.getByTestId("content-bottom-inset").textContent).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BottomSheetHandle,
|
||||
useBottomSheetGestureHandlers,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
@@ -25,7 +30,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
|
||||
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
|
||||
|
||||
@@ -40,6 +45,9 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||
// glyph should line up with the X must reach the same rail.
|
||||
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
|
||||
|
||||
/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */
|
||||
export const FULL_SHEET_SNAP_POINTS = ["92%"];
|
||||
|
||||
export interface SheetHeaderSearch {
|
||||
onChange: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
@@ -244,32 +252,55 @@ const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
|
||||
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
}));
|
||||
|
||||
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
|
||||
|
||||
/**
|
||||
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
|
||||
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
|
||||
* sheet whose content scrolls on its own keeps its header draggable without content panning.
|
||||
*/
|
||||
/** The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. */
|
||||
export function SheetHandle({
|
||||
children,
|
||||
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
|
||||
indicatorStyle: _indicatorStyle,
|
||||
...props
|
||||
}: ComponentProps<typeof BottomSheetHandle>) {
|
||||
if (!children) return <ThemedBottomSheetHandle {...props} />;
|
||||
// The children carry their own semantics; the handle's slider role would swallow them on web.
|
||||
return <ThemedBottomSheetHandle {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content that drags the sheet the way its handle does. A sheet whose body scrolls on its own
|
||||
* turns content panning off and wraps its header in this, so the header still pulls it down.
|
||||
* Render it inside the sheet's content.
|
||||
*/
|
||||
export function SheetDragArea({ children }: { children: ReactNode }) {
|
||||
const { handlePanGestureHandler } = useBottomSheetGestureHandlers();
|
||||
const gesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.shouldCancelWhenOutside(false)
|
||||
.runOnJS(false)
|
||||
.onStart(handlePanGestureHandler.handleOnStart)
|
||||
.onChange(handlePanGestureHandler.handleOnChange)
|
||||
.onEnd(handlePanGestureHandler.handleOnEnd)
|
||||
.onFinalize(handlePanGestureHandler.handleOnFinalize),
|
||||
[handlePanGestureHandler],
|
||||
);
|
||||
return (
|
||||
<ThemedBottomSheetHandle
|
||||
{...props}
|
||||
style={HANDLE_WITH_CONTENT_STYLE}
|
||||
accessible={false}
|
||||
accessibilityRole={null}
|
||||
accessibilityLabel={null}
|
||||
accessibilityHint={null}
|
||||
>
|
||||
{children}
|
||||
</ThemedBottomSheetHandle>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<View>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact sheet's content area that reaches the bottom of the screen. It owns the system
|
||||
* navigation inset: it pads it once and tells its content none is left, so content that pads the
|
||||
* inset itself, like the composer dock, sits the same in a sheet as in a tab.
|
||||
*/
|
||||
export function SheetBottomInset({ style, children, ...props }: ViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentInsets = useMemo(() => ({ ...insets, bottom: 0 }), [insets]);
|
||||
const insetStyle = useMemo(() => [style, { paddingBottom: insets.bottom }], [style, insets]);
|
||||
return (
|
||||
<View {...props} style={insetStyle}>
|
||||
<SafeAreaInsetsContext.Provider value={contentInsets}>
|
||||
{children}
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,6 +519,76 @@ export interface AdaptiveModalSheetProps {
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact sheet's body. It renders on the far side of the sheet's portal so it reads the
|
||||
* screen's bottom inset, not the one left where the sheet was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function CompactSheetBody({
|
||||
header,
|
||||
onClose,
|
||||
testID,
|
||||
footer,
|
||||
isKeyboardVisible,
|
||||
scrollable,
|
||||
bodyStyle,
|
||||
contentStyle,
|
||||
children,
|
||||
}: {
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
footer: ReactNode;
|
||||
isKeyboardVisible: boolean;
|
||||
scrollable: boolean;
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { contentPaddingBottom = 0, footerPaddingBottom = 0 } = getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
safeAreaBottom: insets.bottom,
|
||||
});
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: contentPaddingBottom };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: footerPaddingBottom }),
|
||||
[footerPaddingBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerClearanceStyle}>{footer}</View> : null),
|
||||
[footer, footerClearanceStyle],
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
header,
|
||||
visible,
|
||||
@@ -508,34 +609,11 @@ export function AdaptiveModalSheet({
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isKeyboardVisible = useKeyboardVisibility(visible);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: isMobile,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile],
|
||||
);
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() =>
|
||||
footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
|
||||
[footer, footerContainerStyle],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
@@ -614,32 +692,6 @@ export function AdaptiveModalSheet({
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
@@ -658,7 +710,18 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
@@ -28,7 +28,6 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
@@ -56,18 +55,6 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -91,17 +78,19 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<LightboxImageArea>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
@@ -117,15 +106,38 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
uri={url}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</LightboxImageArea>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</WindowChromeRootRegion>
|
||||
</SafeAreaProvider>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxImageArea({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const style = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
return (
|
||||
<View pointerEvents="box-none" style={style}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -3,11 +3,10 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
|
||||
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
|
||||
* hangs from that center so opened rows read as children of the line that opened them.
|
||||
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so
|
||||
* expanded tool cards stay in place, with a gap between their border and the rail.
|
||||
*/
|
||||
const RAIL_LEFT = 7;
|
||||
const RAIL_LEFT = 1;
|
||||
const RAIL_WIDTH = 1;
|
||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MessagePreview, MessageSender } from "@/components/user-message";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||
@@ -26,6 +27,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
|
||||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
@@ -89,6 +91,7 @@ export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
interface UserMessageProps {
|
||||
sender?: AgentMessage["sender"];
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
@@ -387,6 +390,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
||||
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
sender,
|
||||
serverId,
|
||||
agentId,
|
||||
messageId,
|
||||
@@ -473,42 +477,45 @@ export const UserMessage = memo(function UserMessage({
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
<MessagePreview>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</MessagePreview>
|
||||
</View>
|
||||
{hasText ? (
|
||||
<View
|
||||
@@ -541,7 +548,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
});
|
||||
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
getContent?: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number | null;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
@@ -641,10 +648,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
@@ -2233,6 +2242,9 @@ interface ToolCallProps {
|
||||
defaultExpanded?: boolean;
|
||||
forceInline?: boolean;
|
||||
maxDetailHeight?: number;
|
||||
itemId: string;
|
||||
/** Shown as its own row rather than inside a tool call group. */
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
@@ -2254,9 +2266,15 @@ export const ToolCall = memo(function ToolCall({
|
||||
defaultExpanded,
|
||||
forceInline = false,
|
||||
maxDetailHeight = 400,
|
||||
itemId,
|
||||
standalone = true,
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
|
||||
const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({
|
||||
callId: itemId,
|
||||
defaultExpanded: defaultExpanded ?? false,
|
||||
standalone,
|
||||
});
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const shouldRenderInline = !isMobile || forceInline;
|
||||
@@ -2311,10 +2329,11 @@ export const ToolCall = memo(function ToolCall({
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
toggleExpanded();
|
||||
}
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toggleExpanded,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.detail,
|
||||
@@ -2420,5 +2439,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||||
if (previous.forceInline !== next.forceInline) return false;
|
||||
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
|
||||
if (previous.itemId !== next.itemId) return false;
|
||||
if (previous.standalone !== next.standalone) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
: undefined;
|
||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...planTimelineTailFetch(),
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
|
||||
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
|
||||
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2705,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginRight: -6,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
|
||||
// dots on the same rail as the 24px desktop box and the row at its 44px height.
|
||||
width: { xs: 32, md: 24 },
|
||||
height: { xs: 32, md: 24 },
|
||||
marginRight: { xs: -4, md: 0 },
|
||||
marginVertical: { xs: -4, md: 0 },
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import {
|
||||
@@ -70,13 +71,11 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
|
||||
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
|
||||
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
|
||||
|
||||
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
|
||||
}
|
||||
|
||||
export interface SidebarWorkspaceMenuProps {
|
||||
@@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
padding: 2,
|
||||
// Larger on compact so the kebab is an easy tap target.
|
||||
width: { xs: 32, md: 18 },
|
||||
height: { xs: 32, md: 18 },
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
// Center the compact box on the 20px title line it is anchored to.
|
||||
marginTop: { xs: -6, md: 0 },
|
||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
||||
marginRight: -7,
|
||||
marginRight: { xs: -14, md: -7 },
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useWorkspaceCreation } from "@/workspace-creation";
|
||||
import { CreatingWorkspaceRow } from "@/workspace-creation/ui";
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
@@ -60,16 +58,7 @@ interface SidebarWorkspaceRowProps {
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceRow(props: SidebarWorkspaceRowProps) {
|
||||
const creation = useWorkspaceCreation(props.workspace.serverId, props.workspace.workspaceId);
|
||||
if (creation && !creation.workspaceObserved)
|
||||
return (
|
||||
<CreatingWorkspaceRow attempt={creation} onPress={props.onPress} selected={props.selected} />
|
||||
);
|
||||
return <ReadySidebarWorkspaceRow {...props} />;
|
||||
}
|
||||
|
||||
function ReadySidebarWorkspaceRow({
|
||||
export function SidebarWorkspaceRow({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user