mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb93fda060 |
@@ -169,34 +169,6 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PASEO_REAL_XCRUN="$(command -v xcrun)"
|
||||
stapler_bin="$(mktemp -d)"
|
||||
trap 'rm -rf "$stapler_bin"' EXIT
|
||||
cat > "$stapler_bin/xcrun" <<'STAPLER'
|
||||
#!/bin/bash
|
||||
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
|
||||
exec "$PASEO_REAL_XCRUN" "$@"
|
||||
fi
|
||||
output="$(mktemp)"
|
||||
trap 'rm -f "$output"' EXIT
|
||||
for attempt in 1 2 3 4; do
|
||||
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
|
||||
cat "$output"
|
||||
exit 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
cat "$output"
|
||||
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
|
||||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
|
||||
exit "$status"
|
||||
fi
|
||||
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
|
||||
sleep 30
|
||||
done
|
||||
STAPLER
|
||||
chmod +x "$stapler_bin/xcrun"
|
||||
export PATH="$stapler_bin:$PATH"
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
|
||||
-145
@@ -85,151 +85,6 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
|
||||
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
|
||||
{
|
||||
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
|
||||
"rules": {
|
||||
"paseo/no-sync-io": [
|
||||
"error",
|
||||
{
|
||||
"allow": {
|
||||
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
|
||||
"executable-resolution/executable-resolution.ts": ["existsSync"],
|
||||
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
|
||||
"executable-resolution/windows.ts": ["readdirSync"],
|
||||
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
|
||||
"server/agent/providers/claude/agent.ts": ["existsSync"],
|
||||
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
|
||||
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
|
||||
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
|
||||
"server/agent/providers/claude/subagents/workflow-output.ts": [
|
||||
"closeSync",
|
||||
"openSync",
|
||||
"readSync"
|
||||
],
|
||||
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
|
||||
"server/agent/providers/omp/agent.ts": ["existsSync"],
|
||||
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
|
||||
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
|
||||
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
|
||||
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
|
||||
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
|
||||
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
|
||||
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
|
||||
"server/agent/providers/pi/agent.ts": [
|
||||
"existsSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
|
||||
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
|
||||
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
|
||||
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
|
||||
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
|
||||
"server/config.ts": ["existsSync"],
|
||||
// Startup identity: load or create the daemon keypair before accepting connections.
|
||||
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
|
||||
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
|
||||
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
|
||||
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
|
||||
"server/host-name.ts": ["execFileSync"],
|
||||
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
|
||||
"server/hub/relationship-controller.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
|
||||
"server/local-credential.ts": ["readFileSync"],
|
||||
// Startup logger construction: create the log directory before opening the logging destination.
|
||||
"server/logger.ts": ["mkdirSync"],
|
||||
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
|
||||
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
|
||||
// Module initialization: the package version is resolved once and cached at import.
|
||||
"server/package-version.ts": ["existsSync", "readFileSync"],
|
||||
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
|
||||
"server/persisted-config.ts": ["existsSync", "readFileSync"],
|
||||
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
|
||||
"server/pid-lock.ts": ["readFileSync"],
|
||||
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
|
||||
"server/plugins/builtin/index.ts": ["existsSync"],
|
||||
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
|
||||
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
|
||||
// Build child process: select the esbuild binary for the isolated plugin compiler.
|
||||
"server/plugins/compiler-esbuild.ts": ["existsSync"],
|
||||
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
|
||||
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
|
||||
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
|
||||
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
|
||||
"server/private-files.ts": [
|
||||
"chmodSync",
|
||||
"mkdirSync",
|
||||
"realpathSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
|
||||
"server/push/token-store.ts": ["existsSync", "readFileSync"],
|
||||
// Startup identity: load or create the server id before accepting connections.
|
||||
"server/server-id.ts": ["existsSync", "readFileSync"],
|
||||
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
|
||||
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
|
||||
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
|
||||
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
|
||||
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
|
||||
// Speech child process: native addon discovery and patchelf run outside the daemon.
|
||||
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
|
||||
"existsSync",
|
||||
"spawnSync"
|
||||
],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
|
||||
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
|
||||
"server/web-ui.ts": ["readFileSync", "statSync"],
|
||||
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
|
||||
"server/workspace-registry-bootstrap.ts": ["statSync"],
|
||||
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
|
||||
"terminal/agent-hooks/agent-hook-installer.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
|
||||
"terminal/terminal-ts-loader.mjs": ["existsSync"],
|
||||
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
|
||||
"terminal/terminal.ts": [
|
||||
"accessSync",
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"statSync"
|
||||
],
|
||||
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
|
||||
"utils/path.ts": ["realpathSync"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
||||
"packages/server/src/**/test-utils/**",
|
||||
"packages/server/src/server/daemon-e2e/**",
|
||||
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
|
||||
],
|
||||
"rules": { "paseo/no-sync-io": "off" }
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"plugins/*-provider/server/**/*.ts",
|
||||
|
||||
+4
-26
@@ -1,21 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
|
||||
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
@@ -38,7 +23,7 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
@@ -47,7 +32,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
@@ -64,11 +48,10 @@ paseo host add 'relay://...' --label laptop
|
||||
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### Fixed
|
||||
@@ -83,11 +66,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
@@ -95,7 +73,7 @@ paseo host add 'relay://...' --label laptop
|
||||
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
|
||||
+4
-26
@@ -1,21 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
|
||||
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
@@ -38,7 +23,7 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### 改进
|
||||
|
||||
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
@@ -47,7 +32,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
@@ -64,11 +48,10 @@ paseo host add 'relay://...' --label laptop
|
||||
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### 修复
|
||||
@@ -83,11 +66,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
@@ -95,7 +73,7 @@ paseo host add 'relay://...' --label laptop
|
||||
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
|
||||
+3
-49
@@ -25,54 +25,8 @@ Reload releases the old runtime before resuming its durable session: an idle pro
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
|
||||
Collection releases the process and preserves the agent record, conversation, relationships, and
|
||||
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
|
||||
host config to disable automatic collection. The host setting applies immediately; config-file edits
|
||||
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
|
||||
started finishes. Re-enabling keeps the existing idle timestamps.
|
||||
|
||||
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
|
||||
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
|
||||
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
|
||||
An idle managed child can still own background work.
|
||||
|
||||
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
|
||||
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
|
||||
check, the baseline decides collection after the same one-hour idle period. Once protected work
|
||||
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
|
||||
|
||||
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
|
||||
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
|
||||
Finish subscriptions expose caller protection through their public capability; the collector never
|
||||
reads their watches or durable records.
|
||||
|
||||
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
|
||||
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
|
||||
after closure starts waits for release and resumes the saved session.
|
||||
|
||||
Provider checks follow the signals their runtimes expose:
|
||||
|
||||
- Claude uses its process-local background roster and pending completion notifications. A fresh
|
||||
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
|
||||
demonstrate support by emitting one. Unknown versions stay resident until then.
|
||||
- Codex queries background terminals for loaded threads, including native children, and protects
|
||||
pending provider requests. App servers without the terminal-list RPC stay resident.
|
||||
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
|
||||
inbox inventories. The shared server remains leased while a session vetoes collection.
|
||||
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
|
||||
subagent signals also protect their parent through the daemon's subagent store.
|
||||
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
|
||||
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
|
||||
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
|
||||
runtimes without either field stay resident. These signals are not an inventory of arbitrary
|
||||
processes.
|
||||
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
|
||||
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
|
||||
prevent collection. This does not inspect processes launched internally by the provider.
|
||||
|
||||
These checks do not discover arbitrary detached processes or work hidden by third-party
|
||||
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
@@ -288,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
+41
-20
@@ -221,6 +221,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -238,8 +244,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
pluginsEnabled: boolean,
|
||||
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
|
||||
features: {
|
||||
dictation: { enabled, stt: SpeechSttConfig },
|
||||
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -347,27 +353,42 @@ Environment variables override `config.json`:
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
### Speech configuration
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
Each use site owns its provider settings: `features.dictation.stt`,
|
||||
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
|
||||
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
|
||||
it regardless of provider. `providers.local.modelsDir` remains shared storage.
|
||||
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs
|
||||
into OpenAI sites that lack their own values, using endpoint settings before shared
|
||||
settings. A provider-less site becomes local; provider names are lower-cased, and
|
||||
fields ignored by the selected speech provider are discarded. The next save writes only the
|
||||
inline shape. A legacy key with no site to receive it is discarded with a warning
|
||||
that names its path, without logging its value.
|
||||
| Env var | Applies to |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same
|
||||
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
|
||||
and report effective env sources in `envOverrides`. A site patch that changes
|
||||
`provider` replaces the entire object. The same or omitted `provider` merges fields
|
||||
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
|
||||
options from the previous variant.
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-3
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -126,8 +126,6 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
@@ -542,39 +542,6 @@ patch every build the consumers use: Metro resolves the `react-native` field of
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
## Dependency overrides and workspace tooling
|
||||
|
||||
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
|
||||
`react-native`, …). npm does not apply those overrides to packages it places under a
|
||||
workspace's own `node_modules`. When `expo-module-scripts` was declared in
|
||||
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
|
||||
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
|
||||
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
|
||||
where the overrides apply; workspace scripts still find the binary through the root
|
||||
`node_modules/.bin`.
|
||||
|
||||
## Dependency signature audit
|
||||
|
||||
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
|
||||
signature and, for packages published with provenance, fetches
|
||||
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
|
||||
for a year. An edge that cached it in the seconds before the registry attached the publish
|
||||
attestation keeps serving a document with only the provenance bundle, and npm fails with:
|
||||
|
||||
```
|
||||
npm error code EMISSINGSIGNATUREKEY
|
||||
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
|
||||
```
|
||||
|
||||
The same package's registry signature verified a moment earlier with the same keys, so the
|
||||
key list is intact. The failure names one package, repeats only for that package, and depends
|
||||
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
|
||||
rerun passes only by landing elsewhere.
|
||||
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
|
||||
@@ -94,12 +94,6 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
|
||||
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
|
||||
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
|
||||
`.dev/agent-device-artifacts/composer-keyboard-ios`.
|
||||
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
|
||||
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
|
||||
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
|
||||
under an eight-line draft, selection with the keyboard shown and hidden, and
|
||||
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
|
||||
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
|
||||
+1
-13
@@ -430,15 +430,8 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
||||
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
||||
re-derived on each open and following the plugin's lifecycle. See
|
||||
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
||||
`plugin-examples/lifecycle-logger` registers all
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
|
||||
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
|
||||
executable definitions stay in the plugin process.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
@@ -505,11 +498,6 @@ Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
the plugin bridge transports that query through the existing provider request/event protocol.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
|
||||
|
||||
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
|
||||
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
|
||||
|
||||
Two contracts follow from it.
|
||||
|
||||
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
|
||||
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
|
||||
|
||||
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
+10
-25
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
|
||||
The invariants are:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||
> agent establishes the daemon's current tail in one bounded request, with older history reachable
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
@@ -44,21 +44,7 @@ recovery is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
Opening or resuming an agent fetches the latest available turns with a soft target of 40
|
||||
projected items. Backward pages and prompt-jump windows also include whole turns, including
|
||||
all currently available rows of an active run. There is no per-turn cap: a large run can
|
||||
exceed the target. Local history mounting uses the same boundaries so revealing older rows
|
||||
does not change the collapse state of a turn already displayed.
|
||||
|
||||
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
|
||||
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
|
||||
Reading history remains available on older hosts, whose pages can still split turns. Forward
|
||||
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
|
||||
Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
@@ -163,13 +149,12 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
|
||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||
history. Code outside the owner does not distinguish cached and network timelines.
|
||||
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail of complete available turns. The item target is soft;
|
||||
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
|
||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
@@ -253,11 +238,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Every canonical run owns a visible turn, including heartbeats and agent notifications.
|
||||
Notifications delivered during a run stay inside it; batched notifications share the run
|
||||
that starts or accepts them. Received messages retain sender attribution, while lifecycle
|
||||
notifications remain visible events. Collapse, timing and locally mounted history share
|
||||
these boundaries.
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
Generated
+4131
-2357
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.2",
|
||||
"version": "0.13.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -131,7 +131,6 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"expo-module-scripts": "^5.0.8",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
@@ -155,7 +154,7 @@
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-worklets": "0.10.4"
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-worklets": "0.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,7 @@ import {
|
||||
expectComposerVisible,
|
||||
cancelAgent,
|
||||
composerLocator,
|
||||
expectQueuedMessageButton,
|
||||
fillComposerDraft,
|
||||
holdSendButton,
|
||||
sendDraftToQueue,
|
||||
startRunningMockAgent,
|
||||
submitMessage,
|
||||
@@ -53,7 +51,6 @@ import {
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
@@ -987,7 +984,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
|
||||
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -1004,10 +1001,8 @@ test.describe("Agent message submission", () => {
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await page.reload();
|
||||
|
||||
// The complete turn arrives on reload; scrolling mounts its oldest rows
|
||||
// without requiring another page or an increase in total content height.
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expect(submittedPrompt).toBeVisible();
|
||||
await expect(submittedPrompt).toHaveCount(0);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
@@ -1295,49 +1290,6 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while the user holds Send.",
|
||||
});
|
||||
try {
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
const queuedPrompt = "Queue this follow-up by holding Send.";
|
||||
await test.step("holding Send queues the draft", async () => {
|
||||
await fillComposerDraft(page, queuedPrompt);
|
||||
await holdSendButton(page, "Send and steer");
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("tapping Send still steers", async () => {
|
||||
const steeredPrompt = "Steer this turn with a tap.";
|
||||
await fillComposerDraft(page, steeredPrompt);
|
||||
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
|
||||
|
||||
const request = await gate.waitForRequest();
|
||||
expect(request.text).toBe(steeredPrompt);
|
||||
expect(request.activeTurnBehavior).toBe("steer");
|
||||
gate.accept();
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
|
||||
).toHaveCount(1);
|
||||
await expectQueuedMessageButton(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
|
||||
|
||||
@@ -27,10 +27,6 @@ import {
|
||||
seedRealisticOlderTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
expectToolCallGroupPositionPreserved,
|
||||
rememberToolCallGroupPosition,
|
||||
seedItemPagedToolGroupTimeline,
|
||||
useChatDisplay,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -104,32 +100,6 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts paged by items can end a page inside a tool run. The older page extends the group
|
||||
// the reader is on, which changes the row hosting it; the reader must stay with that group
|
||||
// rather than land at history start and drain every older page.
|
||||
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseToolCalls");
|
||||
const agent = await seedItemPagedToolGroupTimeline();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the user navigates to history start with the keyboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function readFileLinkTooltip(page: Page): Promise<void> {
|
||||
const link = page.getByRole("link", { name: "the guide", exact: true });
|
||||
await link.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("guide.md");
|
||||
}
|
||||
|
||||
async function leaveFileLink(page: Page): Promise<void> {
|
||||
await page.mouse.move(5, 5);
|
||||
await expect(page.getByRole("tooltip")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openLinkedFile(page: Page): Promise<void> {
|
||||
await page.getByRole("link", { name: "the guide", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function returnToAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function switchToFileTab(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "guide.md", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "tooltip-5811-",
|
||||
title: "Tooltip regression",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "guide.md"),
|
||||
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await openLinkedFile(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await switchToFileTab(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1415,14 +1415,12 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
|
||||
if (settings.codeFontSize !== undefined) {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
|
||||
@@ -99,16 +99,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
|
||||
async function pressInComposer(page: Page, key: string) {
|
||||
await composerLocator(page).press(key);
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,14 +42,6 @@ const test = base.extend<{ agent: MockAgentSession }>({
|
||||
},
|
||||
});
|
||||
|
||||
// The outer meter owns the accessible usage label; its SVG is decorative.
|
||||
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
|
||||
const ring = meter.locator("svg");
|
||||
await expect(ring).toHaveAttribute("aria-hidden", "true");
|
||||
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
|
||||
await expect(ring).not.toHaveAttribute("importantForAccessibility");
|
||||
}
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
||||
@@ -108,7 +100,6 @@ test.describe("context window meter", () => {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
||||
|
||||
await expectDecorativeContextRing(meter);
|
||||
const centroid = await progressArcCentroid(meter);
|
||||
expect(centroid.x).toBeGreaterThan(1);
|
||||
expect(centroid.y).toBeLessThan(-1);
|
||||
|
||||
@@ -229,143 +229,6 @@ test("captures the desktop import sheet and command-center entry", async ({ page
|
||||
});
|
||||
});
|
||||
|
||||
test("large import lists keep mounted rows bounded and remain scrollable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
|
||||
await mkdir(fixtureDirectory, { recursive: true });
|
||||
try {
|
||||
const newest = Date.now();
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const id = `virtualized-${String(index).padStart(3, "0")}`;
|
||||
const file = path.join(fixtureDirectory, `${id}.jsonl`);
|
||||
await writeFile(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: id,
|
||||
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
|
||||
message: {
|
||||
role: "user",
|
||||
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const timestamp = new Date(newest - index * 10);
|
||||
await utimes(file, timestamp, timestamp);
|
||||
}
|
||||
for (const width of [1280, 390]) {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
|
||||
await flow.openGlobally();
|
||||
const rows = page.locator('[data-testid^="import-session-session-"]');
|
||||
const scrollToEnd = async (end = true) => {
|
||||
// Scroll the body's real overflow element. Virtual rows need not exist yet.
|
||||
await rows.first().evaluate((row, atEnd) => {
|
||||
let element = row.parentElement;
|
||||
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
|
||||
element = element.parentElement;
|
||||
}
|
||||
if (!element) throw new Error("Import list has no scroll owner");
|
||||
element.scrollTop = atEnd ? element.scrollHeight : 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}, end);
|
||||
};
|
||||
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
|
||||
const loadMore = page.getByTestId("import-session-load-more");
|
||||
await expect(async () => {
|
||||
await scrollToEnd();
|
||||
await expect(loadMore).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await loadMore.click();
|
||||
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
|
||||
}
|
||||
await expect(async () => {
|
||||
await scrollToEnd();
|
||||
await expect(
|
||||
page.getByTestId("import-session-session-claude-virtualized-199"),
|
||||
).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await expect.poll(() => rows.count()).toBeLessThan(100);
|
||||
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
|
||||
if (width === 1280) {
|
||||
const row = (index: number) =>
|
||||
page.getByTestId(
|
||||
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
|
||||
);
|
||||
await expect(async () => {
|
||||
await scrollToEnd(false);
|
||||
await expect(row(0)).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await row(0).focus();
|
||||
for (let index = 1; index <= 30; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(row(index)).toBeFocused();
|
||||
}
|
||||
for (let index = 29; index >= 15; index -= 1) {
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(row(index)).toBeFocused();
|
||||
}
|
||||
const list = page.getByTestId("import-session-list");
|
||||
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
|
||||
const listener = await list.evaluateHandle((node, eventName) => {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
node.addEventListener(eventName, () => resolve(), { once: true });
|
||||
});
|
||||
return { promise };
|
||||
}, event);
|
||||
try {
|
||||
await page.keyboard.press(key);
|
||||
await listener.evaluate((value) => value.promise);
|
||||
} finally {
|
||||
await listener.dispose();
|
||||
}
|
||||
};
|
||||
const beforePageDown = await list.evaluate((node) => node.scrollTop);
|
||||
await scrollWithKey("scroll", "PageDown");
|
||||
await expect
|
||||
.poll(() => list.evaluate((node) => node.scrollTop))
|
||||
.toBeGreaterThan(beforePageDown);
|
||||
await expect(row(15)).toBeFocused();
|
||||
await page.keyboard.press("End");
|
||||
await expect(row(15)).toBeFocused();
|
||||
await expect(row(199)).toBeInViewport();
|
||||
// Home must remain at the first row after the native page scroll ends.
|
||||
await scrollWithKey("scrollend", "Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
await expect(row(15)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("End");
|
||||
await expect(row(199)).toBeInViewport();
|
||||
await row(199).focus();
|
||||
await scrollWithKey("scroll", "PageUp");
|
||||
await page.keyboard.press("Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
await scrollWithKey("scrollend", "End");
|
||||
await expect(row(199)).toBeInViewport();
|
||||
await expect(row(199)).toBeFocused();
|
||||
await testInfo.attach("keyboard-scroll-owner", {
|
||||
body: JSON.stringify(
|
||||
await list.evaluate((node) => ({
|
||||
focusedRow: document.activeElement?.getAttribute("data-testid"),
|
||||
scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight,
|
||||
clientHeight: node.clientHeight,
|
||||
})),
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
|
||||
await page.keyboard.press("Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
}
|
||||
await flow.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const outputPath = testInfo.outputPath(name);
|
||||
await page.screenshot({ path: outputPath, fullPage: true });
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// The creation response carries the descriptor captured when the workspace was provisioned. With an
|
||||
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
|
||||
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
|
||||
// late response must not put the old, unpinned row back.
|
||||
|
||||
function pinnedRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
|
||||
let createdId: string | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { entries } = await seeded.client.fetchWorkspaces({
|
||||
filter: { projectId: seeded.projectId },
|
||||
});
|
||||
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
|
||||
return createdId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeTruthy();
|
||||
return createdId!;
|
||||
}
|
||||
|
||||
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
|
||||
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
|
||||
await page.evaluate((rowTestId) => {
|
||||
const state = { missingFrames: 0, stopped: false };
|
||||
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
|
||||
const sample = () => {
|
||||
if (state.stopped) return;
|
||||
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
|
||||
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, testId);
|
||||
return () =>
|
||||
page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
|
||||
).__pinnedRowWatch;
|
||||
state.stopped = true;
|
||||
return state.missingFrames;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("New workspace live pin", () => {
|
||||
test("keeps a pin made before the creation response arrives", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const creation = await delayBrowserAgentCreatedStatus(page);
|
||||
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
// The response is held here, carrying the descriptor from before the pin.
|
||||
await creation.waitForDelayedCreatedStatus();
|
||||
const workspaceId = await findCreatedWorkspaceId(seeded);
|
||||
const rowTestId = pinnedRowTestId(workspaceId);
|
||||
|
||||
await seeded.client.setWorkspacePinned(workspaceId, true);
|
||||
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
|
||||
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const stopWatching = await watchPinnedRow(page, rowTestId);
|
||||
creation.release();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Leave time for the released response to be merged and rendered.
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
expect(await stopWatching()).toBe(0);
|
||||
await expect(pinnedRow).toBeVisible();
|
||||
} finally {
|
||||
creation.release();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,90 +1,29 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
|
||||
import {
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
getTerminalBufferText,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { metroTest as test, expect, type Page } from "../support/fixtures";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectFoldedTurnAtReadingPosition,
|
||||
expectOldestLoadedTurnPartial,
|
||||
expectTimelinePromptVisible,
|
||||
holdOlderHistoryPages,
|
||||
openAgentTimeline,
|
||||
expectTimelinePromptVisible,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
scrollThroughOlderHistoryPages,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
interface PublishedDaemonAgent {
|
||||
serverId: string;
|
||||
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
agent: MockAgentWorkspace;
|
||||
/** Register the host in the browser and apply app settings before the first load. */
|
||||
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startPublishedDaemonAgent(options: {
|
||||
publishedVersion: string;
|
||||
serverIdPrefix: string;
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
}): Promise<PublishedDaemonAgent> {
|
||||
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
publishedVersion: options.publishedVersion,
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
return {
|
||||
serverId,
|
||||
daemon,
|
||||
agent,
|
||||
async seedBrowser(page, appSettings) {
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences, settings }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
|
||||
);
|
||||
},
|
||||
async close() {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
|
||||
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
|
||||
const observationSockets = new Set<unknown>();
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
@@ -98,84 +37,104 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
observationSockets.add(socket);
|
||||
});
|
||||
});
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.2.5",
|
||||
serverIdPrefix: "srv_old_pagination_",
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "timeline-old-daemon-pagination-",
|
||||
title: "Published daemon pagination regression",
|
||||
port: daemon.port,
|
||||
});
|
||||
const { agent, serverId } = published;
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Published daemon pagination regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await published.seedBrowser(page);
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const history = await holdOlderHistoryPages(page, agent, daemon.port);
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
await expectTimelinePromptVisible(
|
||||
page,
|
||||
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
await scrollThroughOlderHistoryPages(page, 3, history);
|
||||
|
||||
history.expectRepeatedEntries();
|
||||
await history.expectOwnedTextRendered("Now I have a clearer picture.");
|
||||
expect(observationSockets.size).toBe(1);
|
||||
|
||||
await test.step("reconnect the browser and reload the old daemon history", async () => {
|
||||
observationSockets.clear();
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
await expect.poll(() => observationSockets.size).toBe(1);
|
||||
await test.step("send a live turn after reconnecting the old daemon", async () => {
|
||||
await daemon.restart();
|
||||
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
|
||||
await expectComposerEditable(page);
|
||||
await submitMessage(
|
||||
page,
|
||||
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
await expectTimelinePromptVisible(
|
||||
page,
|
||||
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
// The browser and the seed client reconnect independently after the restart.
|
||||
await agent.client.connect();
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
|
||||
});
|
||||
|
||||
await test.step("open a terminal and receive its output", async () => {
|
||||
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
||||
expect(terminal.error).toBeNull();
|
||||
const terminalId = terminal.terminal!.id;
|
||||
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
||||
await focusTerminalSurface(page);
|
||||
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
||||
await expect
|
||||
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
|
||||
.toContain("compatibility-terminal-output");
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
|
||||
await agent.client.killTerminal(terminalId);
|
||||
});
|
||||
} finally {
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
|
||||
// starts mid-turn and only folds once the next page completes it; the reader must stay
|
||||
// with that turn instead of landing at history start and draining every older page.
|
||||
test("keeps the reader on the turn that folds when an older item page completes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.13.0-beta.2",
|
||||
serverIdPrefix: "srv_old_fold_",
|
||||
repoPrefix: "timeline-old-daemon-fold-",
|
||||
title: "Published daemon folded turn pagination",
|
||||
});
|
||||
const { agent, serverId } = published;
|
||||
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
|
||||
|
||||
try {
|
||||
for (const prompt of prompts) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
|
||||
const history = await holdOlderHistoryPages(page, agent, {
|
||||
daemonPort: published.daemon.port,
|
||||
});
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, prompts.at(-1)!);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await published.close();
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,8 +136,7 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
@@ -34,218 +34,8 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
|
||||
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
|
||||
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
|
||||
|
||||
function selectedText(page: Page) {
|
||||
return page.evaluate(() => window.getSelection()?.toString().trim());
|
||||
}
|
||||
|
||||
const CHECKS_QUESTION = "Which checks should run?";
|
||||
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
|
||||
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
|
||||
|
||||
async function openLinkedQuestions(page: Page, context: BrowserContext) {
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-option-links-",
|
||||
title: "Question option links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
return { session, card: page.getByTestId("question-form-card").first() };
|
||||
}
|
||||
|
||||
async function dragAcross(page: Page, target: Locator) {
|
||||
const bounds = await target.boundingBox();
|
||||
if (!bounds) throw new Error("Text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
|
||||
const popup = context.waitForEvent("page");
|
||||
await link.click();
|
||||
await expect.poll(async () => (await popup).url()).toBe(url);
|
||||
}
|
||||
|
||||
async function expectChecked(option: Locator, checked: boolean) {
|
||||
await expect(option).toHaveAttribute("aria-checked", String(checked));
|
||||
}
|
||||
|
||||
test.describe("Question option text", () => {
|
||||
test("links in an option label or description open without answering", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const notYet = card.getByRole("radio", { name: "Not yet" });
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
notYet.getByRole("link", { name: "status" }),
|
||||
"https://example.com/status",
|
||||
);
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
done.getByRole("link", { name: "https://example.com/logs" }),
|
||||
"https://example.com/logs",
|
||||
);
|
||||
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
await expectChecked(notYet, false);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting description text never answers, and the label still does", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const description = done.getByTestId("question-form-option-description");
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
|
||||
await done.getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(done, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-select description links and selection leave the option unchanged", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
|
||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
|
||||
const description = lint.getByTestId("question-form-option-description");
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
description.getByRole("link", { name: "https://example.com/lint" }),
|
||||
"https://example.com/lint",
|
||||
);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||
await expectChecked(typecheck, true);
|
||||
await lint.getByText("Lint", { exact: true }).click();
|
||||
await expectChecked(lint, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("question links open without answering, and the question text is selectable", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-links-",
|
||||
title: "Question links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
const question = card.getByTestId("question-form-current-question");
|
||||
await expect(question).toHaveText(LINKED_QUESTION);
|
||||
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
|
||||
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
|
||||
|
||||
const bounds = await question.boundingBox();
|
||||
if (!bounds) throw new Error("Question text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
|
||||
|
||||
const popup = context.waitForEvent("page");
|
||||
await question.getByRole("link", { name: "the setup guide" }).click();
|
||||
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
|
||||
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
|
||||
for (const option of ["Done", "Not yet"]) {
|
||||
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -29,8 +25,6 @@ import {
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
expectIdleAgentRelease,
|
||||
setIdleAgentRelease,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -143,100 +137,6 @@ test.describe("Settings host page", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agent release changes live on one host and stays saved without restarting", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const primaryServerId = getServerId();
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "idle-release-settings",
|
||||
port: daemon.port,
|
||||
});
|
||||
const originalPid = (await client.getDaemonStatus()).pid;
|
||||
const expectLiveSavedSetting = async (enabled: boolean) => {
|
||||
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
|
||||
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
|
||||
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
|
||||
};
|
||||
const hosts = [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Idle release host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
];
|
||||
const openHostAgentsInFreshApp = async (serverId: string) => {
|
||||
await seedSavedSettingsHosts(page, hosts);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
};
|
||||
try {
|
||||
await test.step("idle agent release starts on and turns off", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
|
||||
await test.step("a fresh app reads the saved setting from the same running host", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
await setIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
});
|
||||
|
||||
await test.step("other hosts keep their own setting", async () => {
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openHostSection(page, primaryServerId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
|
||||
const configPath = path.join(daemon.paseoHome, "config.json");
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openHostSection(page, daemon.serverId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
// A real filesystem failure, with the daemon and RPC path intact.
|
||||
await rename(configPath, `${configPath}.saved`);
|
||||
await mkdir(configPath);
|
||||
await page.getByRole("switch", { name: "Release idle agents" }).click();
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
|
||||
"Failed to read",
|
||||
);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await rm(configPath, { recursive: true });
|
||||
await rename(`${configPath}.saved`, configPath);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
clickSettingsBackToWorkspace,
|
||||
openSettingsHostSection,
|
||||
openSettingsSection,
|
||||
selectSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -47,14 +44,6 @@ test.describe("Settings host selection", () => {
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
|
||||
await selectSettingsHost(page, getServerId());
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsHostSection(page, getServerId(), "connections");
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
} finally {
|
||||
await remoteWorkspace.cleanup().catch(() => undefined);
|
||||
await remoteDaemon.close().catch(() => undefined);
|
||||
|
||||
@@ -10,15 +10,6 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
scrollSettingsSidebarToEnd,
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
@@ -52,8 +43,6 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -63,167 +52,7 @@ async function openWorkspace(
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar scroll position", () => {
|
||||
test.use({ viewport: { width: 1000, height: 500 } });
|
||||
|
||||
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
await page.setViewportSize({ width: 1000, height: 1100 });
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
await page.setViewportSize({ width: 1000, height: 500 });
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("opening Settings closes the composer model selector until it is opened again", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-existing-model-",
|
||||
title: "Settings model selector",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
|
||||
});
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
@@ -248,28 +77,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("host choice survives app sections and host screen replacements", async ({ page }) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHostPickerLabel(page, "First horse");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
@@ -340,42 +147,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
title: "Compact tab switcher",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
|
||||
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
|
||||
// Navigate without dismissing the sheet through its backdrop first.
|
||||
await page
|
||||
.getByTestId("menu-button")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.dispatchEvent("click");
|
||||
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -452,15 +223,6 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
@@ -11,16 +10,6 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`)
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expectAgentTabActive(page, agentId);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
interface ComposerFrame {
|
||||
at: number;
|
||||
visible: boolean;
|
||||
@@ -38,9 +27,7 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
|
||||
|
||||
const sample = () => {
|
||||
if (capture.stopped) return;
|
||||
const root = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
|
||||
).find((candidate) => candidate.offsetParent !== null);
|
||||
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
|
||||
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (root && input) {
|
||||
const style = getComputedStyle(input);
|
||||
@@ -191,8 +178,6 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
await openComposerTab(page, firstAgent.id);
|
||||
await openComposerTab(page, secondAgent.id);
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill("Keep this short draft in the composer");
|
||||
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
@@ -209,7 +194,6 @@ test.describe("Settings toggle tab regression", () => {
|
||||
|
||||
const frames = await stopComposerFrameCapture(page);
|
||||
const visibleFrames = frames.filter((frame) => frame.visible);
|
||||
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
|
||||
const tallestIndex = visibleFrames.reduce(
|
||||
(index, frame, candidateIndex) =>
|
||||
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
||||
@@ -275,123 +259,3 @@ test.describe("Settings toggle tab regression", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function selectSendingOption(
|
||||
page: import("@playwright/test").Page,
|
||||
setting: string,
|
||||
option: string,
|
||||
) {
|
||||
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
|
||||
await page.getByRole("menuitem", { name: option, exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function sendDraftWithShortcut(
|
||||
page: import("@playwright/test").Page,
|
||||
draft: string,
|
||||
shortcut: string,
|
||||
) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill(draft);
|
||||
await composer.press(shortcut);
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(page.getByText(draft, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
|
||||
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
|
||||
try {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Send shortcut",
|
||||
});
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
await openComposerTab(page, agent.id);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
|
||||
await test.step("Enter sends by default", async () => {
|
||||
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
|
||||
});
|
||||
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
|
||||
// Let the menu opening animation settle for visual QA.
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
|
||||
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
|
||||
).toBeVisible();
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(composer).toBeVisible();
|
||||
});
|
||||
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
|
||||
await composer.fill("First line");
|
||||
await composer.press("Enter");
|
||||
await expect(composer).toHaveValue("First line\n");
|
||||
await composer.press("Shift+Enter");
|
||||
await expect(composer).toHaveValue("First line\n\n");
|
||||
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
|
||||
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
|
||||
});
|
||||
await test.step("switch back to Enter without changing the default send behavior", async () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await selectSendingOption(page, "Send shortcut", "Enter");
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await composer.fill("Shift still edits");
|
||||
await composer.press("Shift+Enter");
|
||||
await expect(composer).toHaveValue("Shift still edits\n");
|
||||
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
|
||||
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "running-send-shortcut-",
|
||||
title: "Running send shortcut",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
|
||||
await selectSendingOption(page, "Default send", behavior);
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill("Follow-up");
|
||||
await composer.press("Enter");
|
||||
await expect(composer).toHaveValue("Follow-up\n");
|
||||
await composer.press("Control+Enter");
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
|
||||
behavior === "Queue" ? 1 : 0,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
|
||||
}
|
||||
});
|
||||
|
||||
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
|
||||
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
@@ -362,60 +362,3 @@ test("compact groups retain active overflow and reveal the current ordered rows"
|
||||
await fixture.seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
|
||||
await page.evaluate(
|
||||
({ projectKey: scope, workspaceKeys: order }) => {
|
||||
localStorage.setItem(
|
||||
"sidebar-project-workspace-order",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: {
|
||||
projectOrder: [scope],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { [scope]: order },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ projectKey, workspaceKeys },
|
||||
);
|
||||
await reconnectSidebar(page);
|
||||
}
|
||||
|
||||
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
|
||||
try {
|
||||
const second = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: "New manual workspace",
|
||||
});
|
||||
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
|
||||
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
|
||||
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
|
||||
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await reconnectSidebar(page);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await reconnectSidebar(page);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,9 +72,6 @@ async function expectSortingRequiresHostUpdate(page: Page) {
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
|
||||
@@ -125,29 +125,6 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
holdOlderHistoryPages,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function seedAnsweredAgent(title: string) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-collapse-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Why does the chat stop following new output?",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
|
||||
return rows
|
||||
.slice(start + 1, end)
|
||||
.flatMap((row) =>
|
||||
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
|
||||
(group) => group.textContent ?? "",
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCompactMessageToggle(prompt: Locator, label: string) {
|
||||
const button = prompt.getByRole("button", { name: label, exact: true });
|
||||
const text = prompt.getByText(label, { exact: true });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const control = (await button.boundingBox())!;
|
||||
const glyphs = (await text.boundingBox())!;
|
||||
return control.height - glyphs.height;
|
||||
},
|
||||
{ message: "The inline toggle adds no vertical space beyond its text" },
|
||||
)
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
async function expandAndCollapseLongPrompt(page: Page) {
|
||||
const prompt = page.getByTestId("user-message").last();
|
||||
const before = await prompt.boundingBox();
|
||||
const text = prompt.locator('[data-message-text="true"]');
|
||||
const messageLeft = (await text.boundingBox())!.x;
|
||||
await expect
|
||||
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
|
||||
.toBe(messageLeft);
|
||||
await expectCompactMessageToggle(prompt, "Show more");
|
||||
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
|
||||
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
|
||||
.toBe(messageLeft);
|
||||
await expect
|
||||
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThan(before!.height);
|
||||
await expectCompactMessageToggle(prompt, "Show less");
|
||||
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
|
||||
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
|
||||
await prompt.scrollIntoViewIfNeeded();
|
||||
const bounds = (await prompt.boundingBox())!;
|
||||
const body = (await text.boundingBox())!;
|
||||
await page.screenshot({
|
||||
path: "/tmp/message-toggle-aligned.png",
|
||||
clip: {
|
||||
x: body.x - 16,
|
||||
y: bounds.y,
|
||||
width: bounds.x + bounds.width - body.x + 16,
|
||||
height: bounds.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedAnsweredAgent("Turn collapse");
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expandAndCollapseLongPrompt(page);
|
||||
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toHaveText(/^Edited 1 file/);
|
||||
// Text chunks that stream back to back merge into one message, so the reply holds
|
||||
// at least the stream's last chunk.
|
||||
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
await expect(summary).toBeInViewport();
|
||||
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
|
||||
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
|
||||
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("received messages show their sender and open that agent", async ({ page }) => {
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
|
||||
);
|
||||
const recipient = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sender-attribution-",
|
||||
title: "Implementation",
|
||||
});
|
||||
try {
|
||||
const sender = await recipient.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: recipient.cwd,
|
||||
workspaceId: recipient.workspaceId,
|
||||
title: "Reviewer",
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await recipient.client.sendAgentMessage(
|
||||
recipient.agentId,
|
||||
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
|
||||
{ sourceAgentId: sender.id },
|
||||
);
|
||||
await openAgentRoute(page, recipient);
|
||||
await expectComposerVisible(page);
|
||||
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
|
||||
await expect(attribution).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
|
||||
"I reviewed the turn-grouping changes.",
|
||||
);
|
||||
const labelColor = await attribution
|
||||
.getByText("Sent by Reviewer", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "/tmp/sender-attribution.png" });
|
||||
await attribution.click();
|
||||
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
|
||||
} finally {
|
||||
await recipient.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
await expect(reasoning).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToCollapsedTurns(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "heartbeat-turn-",
|
||||
title: "Heartbeat turns",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Inspect the scroll container",
|
||||
});
|
||||
const schedules = agent.client as unknown as Pick<
|
||||
DaemonClient,
|
||||
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
|
||||
>;
|
||||
let scheduleId: string | undefined;
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const summaries = page.getByTestId("turn-summary");
|
||||
await expect(summaries).toHaveCount(1);
|
||||
const previousSummary = await summaries.first().innerText();
|
||||
const previousReplyId = await page
|
||||
.getByTestId("assistant-message")
|
||||
.last()
|
||||
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
|
||||
const created = await schedules.scheduleCreate({
|
||||
name: "Check the completed work",
|
||||
prompt: "Inspect the scroll container again",
|
||||
cadence: { type: "cron", expression: "0 0 1 1 *" },
|
||||
target: { type: "self", agentId: agent.agentId },
|
||||
runOnCreate: false,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
scheduleId = created.schedule!.id;
|
||||
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
|
||||
expect(fired.error).toBeNull();
|
||||
await expect(
|
||||
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
|
||||
).toBeVisible();
|
||||
await expect(summaries.first()).toHaveText(previousSummary);
|
||||
const previousReply = page
|
||||
.locator(`[data-message-id="${previousReplyId}"]`)
|
||||
.getByTestId("assistant-message");
|
||||
await expect(previousReply).toBeVisible();
|
||||
await expect(previousReply).toContainText("(end of synthetic stream)");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expect(summaries).toHaveCount(2);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
|
||||
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
|
||||
} finally {
|
||||
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
apiKeyInput,
|
||||
baseUrlInput,
|
||||
expectSheetClosed,
|
||||
expectSpeechSiteSummary,
|
||||
openSpeechSite,
|
||||
openVoiceSettings,
|
||||
providerOption,
|
||||
readSpeechSite,
|
||||
resetSpeechSite,
|
||||
saveSpeechSite,
|
||||
withUnwritableDaemonConfig,
|
||||
} from "../support/helpers/voice-settings";
|
||||
|
||||
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
|
||||
// download. That makes the provider picker read-only here; the env key covers the key fallback.
|
||||
const ENV_API_KEY = "sk-e2e-env-fallback";
|
||||
const SAVED_API_KEY = "sk-e2e-saved-key";
|
||||
const BASE_URL = "http://127.0.0.1:9/v1";
|
||||
|
||||
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
|
||||
|
||||
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
|
||||
page,
|
||||
}) => {
|
||||
try {
|
||||
await openVoiceSettings(page);
|
||||
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
|
||||
|
||||
const sheet = await test.step("the env var decides the provider", async () => {
|
||||
const opened = await openSpeechSite(page, "Listening");
|
||||
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
|
||||
await expect(providerOption(opened, "Local")).toBeDisabled();
|
||||
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
|
||||
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
|
||||
return opened;
|
||||
});
|
||||
|
||||
await test.step("a saved key and base URL win over the env key", async () => {
|
||||
await apiKeyInput(sheet).fill(SAVED_API_KEY);
|
||||
await baseUrlInput(sheet).fill(BASE_URL);
|
||||
await saveSpeechSite(sheet);
|
||||
await expectSheetClosed(page);
|
||||
await expect
|
||||
.poll(() => readSpeechSite("voiceMode.stt"))
|
||||
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
|
||||
});
|
||||
|
||||
await test.step("reopening shows the key as saved without revealing it", async () => {
|
||||
const reopened = await openSpeechSite(page, "Listening");
|
||||
await expect(apiKeyInput(reopened)).toHaveValue("");
|
||||
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
|
||||
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
|
||||
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
|
||||
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
await test.step("clearing the base URL keeps the saved key", async () => {
|
||||
const sheetAgain = page.getByRole("dialog");
|
||||
await baseUrlInput(sheetAgain).fill("");
|
||||
await saveSpeechSite(sheetAgain);
|
||||
await expectSheetClosed(page);
|
||||
const site = await readSpeechSite("voiceMode.stt");
|
||||
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
|
||||
expect(site).not.toHaveProperty("baseUrl");
|
||||
});
|
||||
} finally {
|
||||
await resetSpeechSite("voiceMode.stt");
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
|
||||
try {
|
||||
await openVoiceSettings(page);
|
||||
const sheet = await openSpeechSite(page, "Speaking");
|
||||
await baseUrlInput(sheet).fill(BASE_URL);
|
||||
|
||||
await withUnwritableDaemonConfig(async () => {
|
||||
await saveSpeechSite(sheet);
|
||||
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
|
||||
});
|
||||
|
||||
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
|
||||
await saveSpeechSite(sheet);
|
||||
await expectSheetClosed(page);
|
||||
await expect
|
||||
.poll(() => readSpeechSite("voiceMode.tts"))
|
||||
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
|
||||
} finally {
|
||||
await resetSpeechSite("voiceMode.tts");
|
||||
}
|
||||
});
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import {
|
||||
expectAgentAbsentFromWorkspaceLayout,
|
||||
seedForeignPinnedAgentTab,
|
||||
} from "../support/helpers/workspace-layout";
|
||||
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import { clickNewChat } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -108,83 +105,6 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const serverId = getServerId();
|
||||
const firstWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-owner-",
|
||||
title: "Original workspace",
|
||||
});
|
||||
const secondWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-restored-",
|
||||
title: "Restored workspace",
|
||||
});
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: "Original workspace agent",
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: "Restored workspace agent",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await seedForeignPinnedAgentTab(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
});
|
||||
await testInfo.attach("hydrated-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expectAgentAbsentFromWorkspaceLayout(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await testInfo.attach("restored-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
const agents = await firstWorkspace.client.fetchAgents();
|
||||
expect(
|
||||
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
|
||||
).toBe(firstWorkspace.workspaceId);
|
||||
expect(
|
||||
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
|
||||
).toBe(secondWorkspace.workspaceId);
|
||||
await testInfo.attach("original-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await secondWorkspace.cleanup();
|
||||
await firstWorkspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an offline notification on the agent route until the host connects", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -28,10 +28,7 @@ function contains(outer, inner) {
|
||||
* while suggestions are open, so its callers read the composer from `reference`: a
|
||||
* snapshot in the same keyboard state with an empty input.
|
||||
*/
|
||||
export function resolveAutocompletePlacement(
|
||||
snapshot,
|
||||
{ expected, keyboardTop, reference, anchorGap },
|
||||
) {
|
||||
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
|
||||
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
|
||||
const nodes = snapshot.data.nodes;
|
||||
const popover = findById(nodes, "composer-autocomplete-popover").rect;
|
||||
@@ -43,10 +40,6 @@ export function resolveAutocompletePlacement(
|
||||
const keyboardY =
|
||||
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
|
||||
const popoverBottom = bottomOf(popover);
|
||||
const gap = composer.y - popoverBottom;
|
||||
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
|
||||
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
|
||||
}
|
||||
if (popoverBottom > composer.y + 1) {
|
||||
throw new Error(
|
||||
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
|
||||
|
||||
@@ -14,8 +14,7 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?
|
||||
const serverId = env("SERVER_ID");
|
||||
const agentId = env("AGENT_ID");
|
||||
const workspaceId = env("WORKSPACE_ID");
|
||||
const scenario = env("SCENARIO", "all");
|
||||
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
|
||||
if (!serverId || !agentId || !workspaceId) {
|
||||
throw new Error(
|
||||
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
|
||||
);
|
||||
@@ -23,12 +22,7 @@ if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
|
||||
const draftId = env("DRAFT_ID", "composer-keyboard");
|
||||
const session = env("SESSION", "composer-keyboard-ios");
|
||||
const device = env("DEVICE", "iPhone 16 Pro");
|
||||
const udid = env("UDID");
|
||||
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
|
||||
const artifactsDir = env(
|
||||
"ARTIFACTS_DIR",
|
||||
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
|
||||
);
|
||||
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
|
||||
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
|
||||
// Partial queries with known best matches. `inserted` is the composer text after
|
||||
// tapping the best match.
|
||||
@@ -41,7 +35,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
@@ -83,7 +77,7 @@ async function settledPlacement(name, keyboard) {
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
|
||||
const placement = resolveAutocompletePlacement(snap, { expected });
|
||||
process.stdout.write(
|
||||
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
|
||||
placement.popover.y + placement.popover.height,
|
||||
@@ -129,34 +123,21 @@ async function clearInput(name) {
|
||||
)?.rect;
|
||||
if (!deleteKey) throw new Error("Missing keyboard delete key");
|
||||
const length = composerText(keyboard).length;
|
||||
for (let remaining = length + 2; remaining > 0; remaining -= 100) {
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(deleteKey.x + deleteKey.width / 2)),
|
||||
String(Math.round(deleteKey.y + deleteKey.height / 2)),
|
||||
"--count",
|
||||
String(Math.min(remaining, 100)),
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
}
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(deleteKey.x + deleteKey.width / 2)),
|
||||
String(Math.round(deleteKey.y + deleteKey.height / 2)),
|
||||
"--count",
|
||||
String(length + 2),
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
}
|
||||
|
||||
// The accessibility value drops a trailing space.
|
||||
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
|
||||
|
||||
async function selectRow(name, row, inserted) {
|
||||
ad("press", String(row.x), String(row.y));
|
||||
const selected = await waitFor(`${name}-selected`, (snap) =>
|
||||
sameText(composerText(snap), inserted),
|
||||
);
|
||||
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
|
||||
process.stdout.write(
|
||||
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
|
||||
async function runCase(host, kind, { text, expected, inserted }) {
|
||||
const name = `${host}-${kind}`;
|
||||
await clearInput(name);
|
||||
await dismissKeyboard(snapshot(`${name}-empty`));
|
||||
@@ -170,32 +151,31 @@ async function runCase(host, kind, { text, expected, inserted, selectionKeyboard
|
||||
await clearInput(`${name}-settled`);
|
||||
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
|
||||
ad("type", text);
|
||||
const opened = assertPlacement(
|
||||
assertPlacement(
|
||||
`${name} opened after keyboard settled`,
|
||||
await settledPlacement(`${name}-open`, true),
|
||||
expected,
|
||||
);
|
||||
if (selectionKeyboard === "shown") {
|
||||
await selectRow(name, opened, inserted);
|
||||
return;
|
||||
}
|
||||
await dismissKeyboard(snapshot(`${name}-before-close`));
|
||||
const closed = assertPlacement(
|
||||
assertPlacement(
|
||||
`${name} keyboard closed`,
|
||||
await settledPlacement(`${name}-closed`, false),
|
||||
expected,
|
||||
);
|
||||
if (selectionKeyboard === "hidden") {
|
||||
await selectRow(name, closed, inserted);
|
||||
return;
|
||||
}
|
||||
focusAtEnd(snapshot(`${name}-before-reopen`));
|
||||
const reopened = assertPlacement(
|
||||
`${name} keyboard opened under open suggestions`,
|
||||
await settledPlacement(`${name}-reopened`, true),
|
||||
expected,
|
||||
);
|
||||
await selectRow(name, reopened, inserted);
|
||||
ad("press", String(reopened.x), String(reopened.y));
|
||||
const selected = await waitFor(`${name}-selected`, (snap) =>
|
||||
sameText(composerText(snap), inserted),
|
||||
);
|
||||
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
|
||||
process.stdout.write(
|
||||
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Drafts list commands for their selected provider, and a fresh install selects none.
|
||||
@@ -236,104 +216,13 @@ async function runHost(host) {
|
||||
await dismissKeyboard(snapshot(`${host}-done`));
|
||||
}
|
||||
|
||||
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
|
||||
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
|
||||
async function runLiveGrowth() {
|
||||
await clearInput("live-growth");
|
||||
ad("type", "Please do the following now /paseo");
|
||||
const before = await settledPlacement("live-growth-before", true);
|
||||
assertPlacement("before growth", before, "/paseo");
|
||||
const inputHeight = (snap) =>
|
||||
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
|
||||
ad("type", "--", "-committee");
|
||||
const grown = await settledPlacement("live-growth-after", true);
|
||||
if (inputHeight(grown) <= inputHeight(before))
|
||||
throw new Error("Draft did not wrap onto a new line");
|
||||
assertPlacement("live growth without reopening", grown, "/paseo-committee");
|
||||
const deleteKey = grown.data.nodes.find(
|
||||
(node) => node.type === "Key" && node.label === "delete",
|
||||
).rect;
|
||||
ad(
|
||||
"press",
|
||||
String(deleteKey.x + deleteKey.width / 2),
|
||||
String(deleteKey.y + deleteKey.height / 2),
|
||||
"--count",
|
||||
"10",
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
const shrunk = await settledPlacement("live-growth-shrunk", true);
|
||||
if (inputHeight(shrunk) !== inputHeight(before))
|
||||
throw new Error("Draft did not shrink back to one line");
|
||||
assertPlacement("live shrink without reopening", shrunk, "/paseo");
|
||||
ad("type", "--", "-committ");
|
||||
const partial = await settledPlacement("live-growth-partial", true);
|
||||
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
|
||||
throw new Error("Could not prepare a partial command for selection");
|
||||
}
|
||||
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
|
||||
ad("press", String(row.x), String(row.y));
|
||||
await waitFor("live-growth-selected", (snap) =>
|
||||
sameText(composerText(snap), "Please do the following now /paseo-committee "),
|
||||
);
|
||||
await clearInput("live-growth-done");
|
||||
}
|
||||
|
||||
async function runBoundedPreview() {
|
||||
const prefix =
|
||||
"Please do the following / testing inline selection.\n" +
|
||||
"Line two with details.\nLine three with details.\nLine four with details.\n" +
|
||||
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
|
||||
await runCase("preview", "long", {
|
||||
text: `${prefix}/openai`,
|
||||
expected: "/openai-docs",
|
||||
inserted: `${prefix}/openai-docs `,
|
||||
selectionKeyboard: "shown",
|
||||
});
|
||||
await runCase("preview", "long-hidden", {
|
||||
text: `${prefix}/openai`,
|
||||
expected: "/openai-docs",
|
||||
inserted: `${prefix}/openai-docs `,
|
||||
selectionKeyboard: "hidden",
|
||||
});
|
||||
await runCase("preview", "longest", {
|
||||
text: `${prefix}/paseo-plug`,
|
||||
expected: "/paseo-plugin",
|
||||
inserted: `${prefix}/paseo-plugin `,
|
||||
selectionKeyboard: "shown",
|
||||
});
|
||||
await runCase("preview", "short", {
|
||||
text: `${prefix}/com`,
|
||||
expected: "/commit",
|
||||
inserted: `${prefix}/commit `,
|
||||
selectionKeyboard: "shown",
|
||||
});
|
||||
await runCase("preview", "files", fileCase);
|
||||
}
|
||||
|
||||
if (scenario !== "all") {
|
||||
ad(
|
||||
"open",
|
||||
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
|
||||
"--platform",
|
||||
"ios",
|
||||
...deviceArgs,
|
||||
);
|
||||
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
|
||||
if (scenario === "growth") await runLiveGrowth();
|
||||
else if (scenario === "previews") await runBoundedPreview();
|
||||
else throw new Error(`Unknown scenario: ${scenario}`);
|
||||
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const hosts = [
|
||||
["chat", `paseo://h/${serverId}/agent/${agentId}`],
|
||||
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
|
||||
["new-workspace", `paseo://new?serverId=${serverId}`],
|
||||
];
|
||||
for (const [host, url] of hosts) {
|
||||
ad("open", url, "--platform", "ios", ...deviceArgs);
|
||||
ad("open", url, "--platform", "ios", "--device", device);
|
||||
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
|
||||
await runHost(host);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Inline code formatting
|
||||
|
||||
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
|
||||
combined emphasis, strikethrough and linked code, plus bold code in headings,
|
||||
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
|
||||
code-to-body size ratio at every heading level; fenced code keeps the code-size
|
||||
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
|
||||
line metrics.
|
||||
|
||||
Use a development client rebuilt from this checkout. Follow the temporary entry
|
||||
point and Metro workflow in [status-ring](../status-ring/README.md), importing
|
||||
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
|
||||
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
|
||||
|
||||
The package patch contains `InlineCodeFormattingTest`, which exercises actual
|
||||
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
||||
span order, code inside paragraph/heading sizes, bold/italic composition, and
|
||||
links preserving code and emphasized prose fonts.
|
||||
It runs in the existing `test:android` CI job. To run only this regression locally,
|
||||
from `packages/app/android` after Expo prebuild:
|
||||
|
||||
```sh
|
||||
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
|
||||
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, ScrollView, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
scroll: { backgroundColor: theme.colors.surface0 },
|
||||
content: { padding: 32, paddingTop: 60 },
|
||||
title: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const source = [
|
||||
"Plain: `MMMMiiii`",
|
||||
"Bold: **`MMMMiiii`**",
|
||||
"Italic: *`MMMMiiii`*",
|
||||
"Both: ***`MMMMiiii`***",
|
||||
"Strike: ~~`MMMMiiii`~~",
|
||||
"Link: [`MMMMiiii`](https://example.com)",
|
||||
"# Heading **`MMMMiiii`**",
|
||||
"## Heading *`MMMMiiii`*",
|
||||
"### Heading [`MMMMiiii`](https://example.com)",
|
||||
"#### Heading ~~`MMMMiiii`~~",
|
||||
"##### Heading ***`MMMMiiii`***",
|
||||
"###### Heading `MMMMiiii`",
|
||||
"- List **`MMMMiiii`**",
|
||||
"> Quote **`MMMMiiii`**",
|
||||
"Normal **bold** and *italic* prose.",
|
||||
].join("\n\n");
|
||||
|
||||
export default function MarkdownFormattingRegression() {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Inline code formatting regression</Text>
|
||||
<Button
|
||||
title={compact ? "Use regular text" : "Use compact text"}
|
||||
onPress={toggleCompact}
|
||||
/>
|
||||
<MarkdownRenderer text={source} compact={compact} />
|
||||
</ScrollView>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Open a connected host with the wide sidebar visible, keyboard closed, and
|
||||
// three-button navigation enabled on an Android tablet. Run against a fresh build:
|
||||
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session, serial] = process.argv;
|
||||
assert(session && serial, "Pass the agent-device session and adb serial");
|
||||
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
|
||||
assert(navBar, "A visible bottom navigation bar is required");
|
||||
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
|
||||
const navigationTop = Number(navBar[1]);
|
||||
const { data } = JSON.parse(
|
||||
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
}),
|
||||
);
|
||||
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
|
||||
assert.equal(
|
||||
focusedApp,
|
||||
data.appBundleId,
|
||||
"Snapshot must belong to the focused app on this device",
|
||||
);
|
||||
for (const id of [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
]) {
|
||||
const button = data.nodes.find((node) => node.identifier === id);
|
||||
assert(button?.rect, `${id} must be visible above the system navigation bar`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
|
||||
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
|
||||
}
|
||||
@@ -180,11 +180,6 @@ const test = daemonTest.extend<{
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
// New installs collapse finished turns; specs assert on the rows inside them, so they
|
||||
// start from the full chat unless they seed their own settings.
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
|
||||
@@ -338,32 +338,11 @@ export async function delayAgentOlderTimelineResponse(
|
||||
return delayAgentTimelineResponse(page, agentId, "before");
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesOptions {
|
||||
daemonPort?: number;
|
||||
/**
|
||||
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
|
||||
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
|
||||
*/
|
||||
itemPagedHost?: boolean;
|
||||
}
|
||||
|
||||
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
|
||||
const envelope = parseWebSocketJson(message) as {
|
||||
message: { payload: { features?: Record<string, unknown> } };
|
||||
};
|
||||
delete envelope.message.payload.features?.wholeTurnTimeline;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function holdAgentOlderTimelinePages(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
options: OlderTimelinePagesOptions = {},
|
||||
daemonPort?: number,
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
const { daemonPort } = options;
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
let repeatedEntryCount = 0;
|
||||
@@ -397,8 +376,7 @@ export async function holdAgentOlderTimelinePages(
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((received) => {
|
||||
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
|
||||
@@ -87,14 +87,6 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
|
||||
export async function holdSendButton(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: label, exact: true }).hover();
|
||||
await page.mouse.down();
|
||||
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -19,9 +19,6 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
// Config updates before provider refresh finishes and dismisses the add sheet.
|
||||
// Resizing or interacting with the model list must wait for that save to finish.
|
||||
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
}
|
||||
await openGlobalNewWorkspaceComposer(this.page);
|
||||
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
|
||||
const button = this.page.getByTestId("new-workspace-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
|
||||
await this.openFromNewWorkspace();
|
||||
}
|
||||
async openFromNewWorkspace() {
|
||||
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
|
||||
await this.page.getByTestId("new-workspace-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -86,29 +86,7 @@ export class ImportSessionFlow {
|
||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
async revealSession(id: string) {
|
||||
const target = this.page.getByTestId(rowTestId(id));
|
||||
if ((await target.count()) === 0) {
|
||||
await this.scrollList(false);
|
||||
await expect(async () => {
|
||||
if ((await target.count()) === 0) await this.scrollList(true);
|
||||
await expect(target).toHaveCount(1, { timeout: 100 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await expect(target).toBeInViewport();
|
||||
}
|
||||
private async scrollList(forward: boolean) {
|
||||
await this.page
|
||||
.locator('[data-testid^="import-session-session-"]')
|
||||
.first()
|
||||
.evaluate((row, advance) => {
|
||||
let element = row.parentElement;
|
||||
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
|
||||
element = element.parentElement;
|
||||
if (!element) throw new Error("Import list has no scroll owner");
|
||||
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}, forward);
|
||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
||||
}
|
||||
async search(query: string) {
|
||||
await this.page.getByTestId("import-session-search").fill(query);
|
||||
@@ -122,8 +100,7 @@ export class ImportSessionFlow {
|
||||
}
|
||||
async loadMore() {
|
||||
await this.page.getByTestId("import-session-load-more").click();
|
||||
await this.revealSession("fixture-root-20");
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||
}
|
||||
async retryProvider(provider: string, label: string) {
|
||||
@@ -134,7 +111,6 @@ export class ImportSessionFlow {
|
||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
||||
}
|
||||
async importSession(id: string) {
|
||||
await this.revealSession(id);
|
||||
await this.page.getByTestId(rowTestId(id)).click();
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await this.page.keyboard.press("Escape");
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
||||
}
|
||||
async expectCommandCenterMatch() {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -149,11 +149,7 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -42,7 +42,6 @@ type HostSection =
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "metadata"
|
||||
| "voice"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
@@ -58,92 +57,6 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
|
||||
await expectAppRoute(page, buildSettingsSectionRoute(section));
|
||||
}
|
||||
|
||||
export async function revealSettingsSidebarSection(
|
||||
page: Page,
|
||||
section: SettingsSection,
|
||||
): Promise<number> {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Settings" })
|
||||
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarScrollSettled(
|
||||
page: Page,
|
||||
offset: number,
|
||||
): Promise<void> {
|
||||
// RN Web emits its final scroll callback 100 ms after scrolling stops.
|
||||
await page.waitForTimeout(200);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
}
|
||||
|
||||
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
|
||||
for (let selection = 0; selection < 5; selection += 1) {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Settings" })
|
||||
.getByRole("button", { name: "Providers", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Settings" })
|
||||
.getByRole("button", { name: "Terminals", exact: true })
|
||||
.click();
|
||||
await expectSettingsHeader(page, "Terminals");
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("settings-sidebar-scroll-body").hover();
|
||||
await page.mouse.wheel(0, -pixels);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size" });
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
|
||||
await scrollBody.hover();
|
||||
await page.mouse.wheel(0, 2000);
|
||||
await expect
|
||||
.poll(() =>
|
||||
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
|
||||
)
|
||||
.toBe(0);
|
||||
const offset = await scrollBody.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
|
||||
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
|
||||
const scrollBody = page.getByTestId("settings-detail-scroll-body");
|
||||
await expect
|
||||
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
|
||||
.toBeGreaterThan(0);
|
||||
await scrollBody.hover();
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
|
||||
const offset = await scrollBody.evaluate((node) => node.scrollTop);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
|
||||
const scrollBody = page.getByTestId("settings-detail-scroll-body");
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
// Host sections are now flat top-level rows under the Host group. Navigate by
|
||||
// clicking the Connections section row; the picker only matters when >1 host.
|
||||
@@ -413,25 +326,6 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
function idleAgentReleaseSwitch(page: Page) {
|
||||
return page
|
||||
.getByTestId("host-page-idle-runtime-cleanup-card")
|
||||
.getByRole("switch", { name: "Release idle agents" });
|
||||
}
|
||||
|
||||
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toBeEnabled();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
|
||||
}
|
||||
|
||||
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
|
||||
await toggle.click();
|
||||
await expectIdleAgentRelease(page, enabled);
|
||||
}
|
||||
|
||||
export async function openHostSection(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
|
||||
@@ -63,9 +63,8 @@ export async function seedSidebarFooterPreferences(
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
if (settings.sidebarFooterItems) return;
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
|
||||
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
|
||||
}
|
||||
|
||||
export async function archiveFinishedSubagents(page: Page): Promise<void> {
|
||||
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click();
|
||||
await page.getByRole("button", { name: "Archive finished subagents" }).click();
|
||||
}
|
||||
|
||||
export async function expectArchiveFinishedInProgress(
|
||||
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
|
||||
completed: number,
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
|
||||
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function holdManagedSubagentArchiveRequest(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
|
||||
import { expect, type TestInfo, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
type OlderTimelinePagesOptions,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
@@ -36,19 +35,6 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
/** Commands in one run, more than an item page holds. */
|
||||
totalCommands: number;
|
||||
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
|
||||
loadedCommands: number;
|
||||
}
|
||||
|
||||
interface ToolCallGroupPositionSnapshot {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const ITEM_PAGE_SIZE = 40;
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
@@ -140,93 +126,6 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of commands longer than an item page, followed by enough short turns that the
|
||||
* initial tail ends inside the run. Read with item pages, the tail loads the newest
|
||||
* commands as one group; the older page carries the rest of that run and the turns before
|
||||
* it, with a further page behind it.
|
||||
*/
|
||||
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-item-page-tool-group-",
|
||||
title: "Item page tool group regression",
|
||||
});
|
||||
const olderTurns = 10;
|
||||
const newerTurns = 12;
|
||||
const totalCommands = 50;
|
||||
// A short turn is its prompt and one reply; the run's turn ends with one reply.
|
||||
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
|
||||
const shortTurnPrompt = (label: string, index: number) =>
|
||||
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
|
||||
const sendTurn = async (prompt: string) => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
};
|
||||
|
||||
for (let index = 0; index < olderTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("older", index));
|
||||
}
|
||||
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
|
||||
for (let index = 0; index < newerTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("newer", index));
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
|
||||
totalCommands,
|
||||
loadedCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export async function useChatDisplay(
|
||||
page: Page,
|
||||
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
function toolCallGroup(page: Page, commandCount: number): Locator {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
return timeline
|
||||
.getByTestId("tool-call-group")
|
||||
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
|
||||
}
|
||||
|
||||
export async function rememberToolCallGroupPosition(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
): Promise<ToolCallGroupPositionSnapshot> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
const box = await group.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
|
||||
}
|
||||
return { top: box.y };
|
||||
}
|
||||
|
||||
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
|
||||
export async function expectToolCallGroupPositionPreserved(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
before: ToolCallGroupPositionSnapshot,
|
||||
): Promise<void> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const box = await group.boundingBox();
|
||||
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||
},
|
||||
{ message: `the group of ${commandCount} commands keeps its viewport position` },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function scrollThroughOlderHistoryPages(
|
||||
page: Page,
|
||||
pageCount: number,
|
||||
@@ -417,14 +316,13 @@ export async function holdNextOlderTimelinePage(
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
options?: OlderTimelinePagesOptions,
|
||||
daemonPort?: number,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||
page,
|
||||
agent.agentId,
|
||||
options,
|
||||
daemonPort,
|
||||
);
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
@@ -432,17 +330,7 @@ export async function holdOlderHistoryPages(
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
// The loader stays until the prepend settles and the viewport decides whether to
|
||||
// keep loading. Once it hides, no further page loads without new user intent.
|
||||
await expect
|
||||
.poll(
|
||||
async () => ({
|
||||
requestedPages: gate.getRequestCount(),
|
||||
loading: await loader.isVisible(),
|
||||
}),
|
||||
{ message: `older history settles after ${count} requested page(s)` },
|
||||
)
|
||||
.toEqual({ requestedPages: count, loading: false });
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
expectNoRepeatedEntries() {
|
||||
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||
@@ -535,53 +423,6 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
|
||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
|
||||
* mid-turn: its prompt is above loaded history. Returns that prompt.
|
||||
*/
|
||||
export async function expectOldestLoadedTurnPartial(
|
||||
page: Page,
|
||||
prompts: readonly string[],
|
||||
): Promise<string> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const oldestLoadedPrompt = await timeline
|
||||
.getByTestId("user-message")
|
||||
.first()
|
||||
.evaluate((element) => element.textContent ?? "");
|
||||
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
|
||||
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
|
||||
const oldestRow = timeline.locator("[data-history-row-id]").first();
|
||||
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
|
||||
0,
|
||||
);
|
||||
const partialPrompt = prompts[loadedIndex - 1]!;
|
||||
await expectTimelinePromptNotMounted(page, partialPrompt);
|
||||
return partialPrompt;
|
||||
}
|
||||
|
||||
/** The reader stays with the turn they were reading once it folds behind its summary. */
|
||||
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [timelineBox, promptBox] = await Promise.all([
|
||||
timeline.boundingBox(),
|
||||
promptRow.boundingBox(),
|
||||
]);
|
||||
if (!timelineBox || !promptBox) return false;
|
||||
const offset = promptBox.y - timelineBox.y;
|
||||
return offset >= 0 && offset <= timelineBox.height / 2;
|
||||
},
|
||||
{ message: "folded turn sits in the upper half of the viewport" },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { rename, mkdir, rmdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
|
||||
|
||||
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
|
||||
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
|
||||
|
||||
export async function openVoiceSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "voice");
|
||||
await expectSettingsHeader(page, "Voice");
|
||||
}
|
||||
|
||||
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
|
||||
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
|
||||
}
|
||||
|
||||
export async function expectSpeechSiteSummary(
|
||||
page: Page,
|
||||
label: SpeechSiteLabel,
|
||||
summary: string,
|
||||
): Promise<void> {
|
||||
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
|
||||
}
|
||||
|
||||
/** Opens the provider sheet for one use site and returns it. */
|
||||
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
|
||||
await speechSiteRow(page, label).click();
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
|
||||
return sheet.getByRole("button", { name: provider, exact: true });
|
||||
}
|
||||
|
||||
export function apiKeyInput(sheet: Locator): Locator {
|
||||
return sheet.getByRole("textbox", { name: "API key" });
|
||||
}
|
||||
|
||||
export function baseUrlInput(sheet: Locator): Locator {
|
||||
return sheet.getByRole("textbox", { name: "Base URL" });
|
||||
}
|
||||
|
||||
export async function saveSpeechSite(sheet: Locator): Promise<void> {
|
||||
await sheet.getByRole("button", { name: "Save", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectSheetClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
|
||||
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
try {
|
||||
const { config } = await client.getDaemonConfig();
|
||||
const features = config.features as Record<string, Record<string, unknown>>;
|
||||
const [feature, site] = siteId.split(".");
|
||||
return features[feature][site] as Record<string, unknown>;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a speech site back to the default provider so later tests start from a known state. */
|
||||
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
try {
|
||||
const [feature, site] = siteId.split(".");
|
||||
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes the daemon's config file unreadable for the duration of `run`. */
|
||||
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
const asidePath = `${configPath}.aside`;
|
||||
await rename(configPath, asidePath);
|
||||
await mkdir(configPath);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
await rmdir(configPath);
|
||||
await rename(asidePath, configPath);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,12 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
||||
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
} from "../../../src/workspace-tabs/model";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function seedForeignPinnedAgentTab(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
if (raw === null) throw new Error("Workspace layout was not persisted");
|
||||
const stored = z
|
||||
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
|
||||
.parse(JSON.parse(raw));
|
||||
const key = buildWorkspaceTabPersistenceKey(input);
|
||||
const layout = key ? stored.state.layoutByWorkspace[key] : null;
|
||||
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
|
||||
const opened = openTabInLayoutFocused({
|
||||
layout,
|
||||
target: { kind: "agent", agentId: input.agentId },
|
||||
now: 1,
|
||||
placement: { mode: "focused" },
|
||||
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
|
||||
});
|
||||
if (!opened) throw new Error("Failed to seed foreign agent tab");
|
||||
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
|
||||
...stored.state,
|
||||
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
|
||||
pinnedAgentIdsByWorkspace: {
|
||||
...stored.state.pinnedAgentIdsByWorkspace,
|
||||
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
|
||||
},
|
||||
});
|
||||
await page.evaluate(
|
||||
(value) => localStorage.setItem("workspace-layout-state", value),
|
||||
JSON.stringify(stored),
|
||||
);
|
||||
}
|
||||
|
||||
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
|
||||
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.2",
|
||||
"version": "0.13.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :expo-modules-core:testReleaseUnitTest --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
@@ -126,14 +126,14 @@
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.10.4",
|
||||
"react-native-worklets": "0.10.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@@ -198,14 +198,6 @@
|
||||
],
|
||||
"enableVideo": false
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native-reanimated",
|
||||
"react-native-worklets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reanimated": {
|
||||
"staticFeatureFlags": {
|
||||
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
||||
|
||||
@@ -24,7 +24,6 @@ function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
holdReadingPosition() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
|
||||
@@ -265,7 +265,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
@@ -317,7 +316,6 @@ describe("useChatOutline", () => {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
@@ -373,7 +371,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
|
||||
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
|
||||
isTurnActive: input.isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||
liveHead: [],
|
||||
timingByItemId: timingFor(assistant.id),
|
||||
timingByAssistantId: timingFor(assistant.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
|
||||
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
||||
|
||||
@@ -33,7 +32,7 @@ export function findMountedWindowStart(input: {
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
|
||||
@@ -104,7 +104,7 @@ function layoutFor(input: {
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByItemId: timingFor(...(input.timingIds ?? [])),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
|
||||
it("places one response footer after an adjacent tagged tool-only turn", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||
const layout = layoutFor({
|
||||
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
|
||||
timingIds: [priorAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
||||
});
|
||||
|
||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByItemId: timingFor(priorAssistant.id),
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByItemId: timingFor(priorAssistant.id),
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
|
||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
||||
isTurnActive: boolean;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface TurnFooterSource {
|
||||
item: StreamItem;
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
@@ -67,26 +67,23 @@ function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByItemId.get(input.item.id),
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
};
|
||||
}
|
||||
|
||||
function findTurnFooterSource(input: {
|
||||
function findLatestAssistantInResponse(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | 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;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
@@ -100,7 +97,7 @@ function findTurnFooterSource(input: {
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||
return toolFallback;
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
@@ -114,7 +111,7 @@ function findTurnFooterSource(input: {
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return toolFallback;
|
||||
return null;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
@@ -134,20 +131,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = findTurnFooterSource({
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!source) {
|
||||
if (!assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByItemId: input.timingByItemId,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,7 +154,7 @@ function resolveCompletedFooter(input: {
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
@@ -166,21 +163,21 @@ function resolveCompletedFooter(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = findTurnFooterSource({
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByItemId: input.timingByItemId,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,7 +306,7 @@ function layoutSegmentItem(
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByItemId: input.timingByItemId,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
@@ -378,7 +375,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.history,
|
||||
timingByItemId: input.timingByItemId,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
@@ -398,7 +395,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.liveHead,
|
||||
timingByItemId: input.timingByItemId,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
|
||||
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
historyStart: 2,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.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.byItemId.has("live-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.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.byItemId.get("live-a")).toEqual({
|
||||
expect(model.turnTiming.byAssistantId.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.byItemId.get("a1")).toEqual({
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byItemId.size).toBe(0);
|
||||
expect(model.turnTiming.byAssistantId.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.byItemId).toBe(first.turnTiming.byItemId);
|
||||
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
|
||||
expect(first.history).toHaveLength(20);
|
||||
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byItemId.get("a38")).toEqual({
|
||||
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
|
||||
completedAt: tail[77]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
getStreamItemMessageId,
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type ToolCallItem,
|
||||
@@ -14,8 +13,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
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";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -99,11 +97,7 @@ function pluginData(items: StreamItem[]): unknown[] {
|
||||
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
|
||||
}
|
||||
|
||||
const presentationOptions = {
|
||||
display: "collapseToolCalls" as const,
|
||||
expandedTurnHostIds: new Set<string>(),
|
||||
isTurnActive: true,
|
||||
};
|
||||
const presentationOptions = { level: "overview" as const, isTurnActive: true };
|
||||
|
||||
function streamHarness(transform?: TimelineItemTransform) {
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
@@ -589,175 +583,3 @@ 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"]);
|
||||
});
|
||||
|
||||
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,5 +1,3 @@
|
||||
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";
|
||||
import {
|
||||
@@ -7,51 +5,23 @@ import {
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
type ToolCallDetailProjection,
|
||||
} 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>;
|
||||
level: ToolCallDetailLevel;
|
||||
isTurnActive: boolean;
|
||||
activeTurnId?: string | null;
|
||||
}
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
readingRowReplacements: ReadingRowReplacements;
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
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({
|
||||
tail: [],
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
@@ -75,87 +45,14 @@ 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,
|
||||
): 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) &&
|
||||
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
|
||||
!(
|
||||
input.activeTurnId &&
|
||||
projection.tail.at(-1)?.turnId &&
|
||||
input.activeTurnId !== projection.tail.at(-1)?.turnId
|
||||
);
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
|
||||
collapsedExpandedHostIds !== input.expandedTurnHostIds
|
||||
) {
|
||||
collapsed = collapseTurns({
|
||||
tail: projection.tail,
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
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 => {
|
||||
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
@@ -169,48 +66,17 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
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();
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||
preparedLevel = input.level;
|
||||
}
|
||||
const projection = projectToolCallDetailLevel({
|
||||
level,
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.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,24 +2,6 @@ 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,10 +6,6 @@ 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.
|
||||
@@ -18,15 +14,10 @@ export function createReadingAnchor() {
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
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);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId,
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
@@ -37,18 +28,13 @@ export function createReadingAnchor() {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
rows: readonly RowGeometry[],
|
||||
userScrolled = false,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
): number {
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
// 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, replacements);
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
// 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("keeps copied content within the canonical turn without a visible prompt", () => {
|
||||
it("collects copy content across adjacent turns 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("second turn");
|
||||
).toBe("first turn\n\nsecond turn");
|
||||
});
|
||||
|
||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||
|
||||
@@ -361,7 +361,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
holdReadingPosition: () => bottomAnchorController.detachByUser(),
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
|
||||
@@ -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 { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
@@ -131,79 +131,6 @@ 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 });
|
||||
|
||||
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
@@ -394,7 +395,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
@@ -404,14 +405,10 @@ 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,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
@@ -432,7 +429,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
@@ -746,12 +743,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(
|
||||
container.scrollTop,
|
||||
rows,
|
||||
false,
|
||||
props.readingRowReplacements,
|
||||
);
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
@@ -1182,11 +1174,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdReadingPosition: () => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
@@ -1199,7 +1186,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
readingAnchor,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -56,8 +55,6 @@ export interface StreamViewportHandle {
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/** Stop following output so the rows on screen stay put while rows below them open or close. */
|
||||
holdReadingPosition: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
@@ -79,8 +76,6 @@ 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,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { collapseTurns, type CollapseTurnsInput } from "./model";
|
||||
|
||||
const at = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
function user(id: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
|
||||
function shell(id: string): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: "ls" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
|
||||
return collapseTurns({
|
||||
tail,
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
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"),
|
||||
thought("t1"),
|
||||
reply("early"),
|
||||
shell("c1"),
|
||||
shell("c2"),
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
|
||||
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
shell("c1"),
|
||||
reply("r1"),
|
||||
shell("after"),
|
||||
user("u2"),
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
const tail = [user("u1"), thought("t1"), reply("r1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
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")], {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
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 {
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The input or first loaded row that carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
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 {
|
||||
tail: StreamItem[];
|
||||
/** The latest turn is still producing output, so it stays unfolded. */
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
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 {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Reasoning is revealed only when the turn opens.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
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 ||
|
||||
(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) && !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 });
|
||||
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);
|
||||
}
|
||||
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 (
|
||||
!(index === 0 && isTurnTrigger(tail[index]!)) &&
|
||||
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
|
||||
)
|
||||
continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail:
|
||||
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||
? tail
|
||||
: output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||
const toggle = useCallback(
|
||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||
[onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
|
||||
getContent={getContent}
|
||||
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,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -67,24 +67,6 @@ 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,
|
||||
@@ -94,7 +76,7 @@ describe("canonical turn membership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a separate footer for each canonical turn without visible prompts", () => {
|
||||
it("renders adjacent turns without visible prompts as one response", () => {
|
||||
const items = [
|
||||
user("prompt", 1, "turn-1"),
|
||||
assistant("first reply", 2, "turn-1"),
|
||||
@@ -106,11 +88,7 @@ describe("canonical turn membership", () => {
|
||||
|
||||
const completed = layoutFor(items, false);
|
||||
|
||||
expect(completedFooterIds(completed.layout)).toEqual([
|
||||
"first reply",
|
||||
"heartbeat-1-reply",
|
||||
"heartbeat-2-reply",
|
||||
]);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||
completedAt: at(6),
|
||||
durationMs: null,
|
||||
@@ -145,7 +123,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.byItemId.get("done")).toMatchObject({
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
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
|
||||
* trigger boundary rule; missing IDs cannot reconstruct historical batching.
|
||||
* user-message boundary rule so old daemons and persisted rows need no rewrite.
|
||||
*/
|
||||
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
|
||||
if (previous.turnId !== undefined && next.turnId !== undefined) {
|
||||
return previous.turnId === next.turnId;
|
||||
}
|
||||
return next.kind !== "user_message";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every canonical turn owns its response, including system-triggered runs.
|
||||
* A visible response can span multiple canonical turns when their prompts are
|
||||
* system-injected and therefore absent from the Paseo timeline.
|
||||
*/
|
||||
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
return continuesTurn(previous, next);
|
||||
return continuesTurn(previous, next) || next.kind !== "user_message";
|
||||
}
|
||||
|
||||
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
@@ -34,12 +37,3 @@ 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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
// The view retains its last visible transcript. Selecting a stable value here
|
||||
// also prevents incoming chunks from scheduling React work in a hidden panel.
|
||||
// Revealing the panel reads the current tail directly from the model.
|
||||
export function useAgentStreamTail(serverId: string, agentId: string | undefined) {
|
||||
const active = useRetainedPanelActive();
|
||||
return useSessionStore((state) =>
|
||||
active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
|
||||
// Callers reveal a message, not a row: an assistant message is several block rows and
|
||||
// the window has to open at the first of them.
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -62,10 +62,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { TurnSummaryLine } from "./turn-collapse/summary-line";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
@@ -150,16 +148,9 @@ function renderPendingPermissionsNode(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
|
||||
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
|
||||
*/
|
||||
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
turnIndent: TurnIndent;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
@@ -180,7 +171,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
@@ -342,7 +333,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const chatDisplay = useSettings((settings) => settings.chatDisplay);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -366,7 +357,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -431,7 +421,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
@@ -523,22 +512,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
level: toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
activeTurnId: effectiveTurnPresentation.turnId,
|
||||
}),
|
||||
[
|
||||
presentStream,
|
||||
effectiveStreamItems,
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
effectiveTurnPresentation.turnId,
|
||||
],
|
||||
);
|
||||
const {
|
||||
@@ -580,12 +563,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByItemId: baseRenderModel.turnTiming.byItemId,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
}),
|
||||
[
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byItemId,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
isTurnActive,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
@@ -668,43 +651,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[streamRenderStrategy],
|
||||
);
|
||||
|
||||
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
|
||||
// A turn opens below its summary line; keep that line under the reader's pointer.
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpandedTurnHostIds((previous) => {
|
||||
if (previous.has(hostId) === expanded) return previous;
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(hostId);
|
||||
} else {
|
||||
next.delete(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Chat find reveals a match inside a collapsed turn by opening the turn first.
|
||||
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
|
||||
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
|
||||
if (hostId && !expandedTurnHostIds.has(hostId)) {
|
||||
setTurnExpanded(hostId, true);
|
||||
return true;
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -719,14 +665,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
messageId={item.messageId}
|
||||
message={item.text}
|
||||
sender={item.sender}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
@@ -740,25 +684,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -873,15 +800,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
presentation.groupsByHostId.get(hostId),
|
||||
);
|
||||
const renderToolCallItem = useCallback(
|
||||
(
|
||||
layoutItem: StreamLayoutItem,
|
||||
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
|
||||
) => {
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const group = getToolCallGroup(item.id);
|
||||
if (!group) {
|
||||
return item.kind === "thought"
|
||||
? renderThoughtItem(layoutItem, item)
|
||||
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
||||
return (
|
||||
@@ -894,21 +816,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{expanded
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{call.kind === "thought" ? (
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)
|
||||
{renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
@@ -918,8 +829,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
renderThoughtItem,
|
||||
setInlineDetailsExpanded,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
@@ -937,20 +846,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderAssistantMessageItem(layoutItem, item);
|
||||
|
||||
case "thought":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
return renderThoughtItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -980,9 +879,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderThoughtItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
);
|
||||
@@ -995,14 +893,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
layoutItem,
|
||||
turnIndent: getTurnIndent(layoutItem),
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[
|
||||
getTurnIndent,
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
@@ -1164,25 +1060,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: {
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
],
|
||||
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
|
||||
);
|
||||
|
||||
const findItems = useMemo(
|
||||
@@ -1196,14 +1080,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
@@ -1880,41 +1763,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
turnIndent: TurnIndent;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [
|
||||
stylesheet.streamItemWrapper,
|
||||
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
|
||||
],
|
||||
[gapBelow, turnIndent],
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
);
|
||||
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} />
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail style={gapStyle}>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
case "lastNested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -70,17 +70,6 @@ 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)];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user