mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de50243885 | ||
|
|
5b7b8d6e81 | ||
|
|
25721c7847 | ||
|
|
78f580f9e8 | ||
|
|
8419c21227 | ||
|
|
5f014498c7 | ||
|
|
4495bda146 | ||
|
|
484c5ba224 | ||
|
|
1b8dbc38ab | ||
|
|
694696e82c | ||
|
|
a8032a1502 | ||
|
|
dc07efe937 | ||
|
|
36892d5be7 | ||
|
|
b9f624f72e | ||
|
|
50623965b4 | ||
|
|
ef58ace424 | ||
|
|
8a542048b6 | ||
|
|
6b7d49f0d8 | ||
|
|
07007a485d | ||
|
|
26ee20b371 | ||
|
|
b12eefb28f | ||
|
|
3a1b146690 | ||
|
|
2ce6681d6e | ||
|
|
63ed15e933 | ||
|
|
9e4aab16cc | ||
|
|
173fca3c0b | ||
|
|
7f6a805e0e | ||
|
|
9bc998fa38 | ||
|
|
67a2459254 | ||
|
|
f14a4cc112 | ||
|
|
a25f4f5065 | ||
|
|
6077bc6219 | ||
|
|
9c7d4ace65 | ||
|
|
8d971876bf | ||
|
|
fc0cbbb323 | ||
|
|
64b0afe010 | ||
|
|
10bc10c99f | ||
|
|
65ce4b590a | ||
|
|
4f9d9d6de2 | ||
|
|
1f3266e2ba | ||
|
|
28834a91ae | ||
|
|
e3bbed3c73 | ||
|
|
42d74c2546 | ||
|
|
678fe308db | ||
|
|
287ca54749 | ||
|
|
902b0c7a16 |
@@ -169,6 +169,34 @@ jobs:
|
|||||||
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
|
|||||||
+145
@@ -85,6 +85,151 @@
|
|||||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"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": [
|
"files": [
|
||||||
"plugins/*-provider/server/**/*.ts",
|
"plugins/*-provider/server/**/*.ts",
|
||||||
|
|||||||
@@ -1,5 +1,74 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.14.0-beta.1 - 2026-10-27
|
||||||
|
|
||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||||
|
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||||
|
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
|
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||||
|
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||||
|
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
|
||||||
|
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
|
||||||
|
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
|
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||||
|
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||||
|
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
|
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||||
|
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
|
||||||
|
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
|
||||||
|
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||||
|
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||||
|
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||||
|
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
|
||||||
|
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
|
||||||
|
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||||
|
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
|
||||||
|
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
|
||||||
|
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
|
||||||
|
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
|
||||||
|
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
|
||||||
|
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
|
||||||
|
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
|
||||||
|
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
|
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
|
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||||
|
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||||
|
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||||
|
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||||
|
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||||
|
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Voice mode now defaults to off; enable it in Voice settings
|
||||||
|
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||||
|
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
||||||
|
|
||||||
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
|
||||||
|
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||||
|
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||||
|
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||||
|
|
||||||
## 0.13.1 - 2026-10-26
|
## 0.13.1 - 2026-10-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,5 +1,74 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## 0.14.0-beta.1 - 2026-10-27
|
||||||
|
|
||||||
|
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||||
|
|
||||||
|
已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。
|
||||||
|
|
||||||
|
语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||||
|
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||||
|
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
|
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||||
|
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||||
|
- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710))
|
||||||
|
- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
|
||||||
|
- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
|
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||||
|
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||||
|
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
|
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||||
|
- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901))
|
||||||
|
- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
|
||||||
|
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||||
|
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||||
|
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||||
|
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
|
||||||
|
- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918))
|
||||||
|
- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||||
|
- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019))
|
||||||
|
- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876))
|
||||||
|
- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955))
|
||||||
|
- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
|
||||||
|
- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
|
||||||
|
- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
|
||||||
|
- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
|
||||||
|
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
|
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
|
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||||
|
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||||
|
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||||
|
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||||
|
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||||
|
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- 语音模式默认关闭,可在语音设置中启用
|
||||||
|
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
||||||
|
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
||||||
|
|
||||||
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
|
||||||
|
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||||
|
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||||
|
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||||
|
|
||||||
## 0.13.1 - 2026-10-26
|
## 0.13.1 - 2026-10-26
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -273,6 +273,26 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
|
|||||||
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
||||||
measurements so profiling overhead does not contaminate them.
|
measurements so profiling overhead does not contaminate them.
|
||||||
|
|
||||||
|
For diff layout, two benchmarks share one corpus
|
||||||
|
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
|
||||||
|
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
|
||||||
|
shared document model with the web and native measurers replaced by counters, so it reports how
|
||||||
|
many strings each backend would have to shape, which is the cost that survives the move to canvas
|
||||||
|
or Skia:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
|
||||||
|
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
|
||||||
|
React commit time for cold and warm toggles, then scroll frame times in both modes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
|
||||||
|
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
|
||||||
|
```
|
||||||
|
|
||||||
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
||||||
That report includes input and composer height changes plus React work grouped into composer,
|
That report includes input and composer height changes plus React work grouped into composer,
|
||||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||||
@@ -542,6 +562,39 @@ patch every build the consumers use: Metro resolves the `react-native` field of
|
|||||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
(`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.
|
(`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
|
## ACP provider catalog versions
|
||||||
|
|
||||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||||
|
|||||||
+17
-1
@@ -382,6 +382,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
|||||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||||
catalog is complete.
|
catalog is complete.
|
||||||
|
|
||||||
|
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||||
|
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||||
|
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||||
|
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||||
|
passing the bundle string still costs memory and time once per client lifetime.
|
||||||
|
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||||
|
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||||
|
in-memory bundles usable, and a later catalog retries persistence.
|
||||||
|
|
||||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||||
@@ -430,8 +439,15 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
|||||||
|
|
||||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
||||||
|
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
||||||
|
re-derived on each open and following the plugin's lifecycle. See
|
||||||
|
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
||||||
|
`plugin-examples/lifecycle-logger` registers all
|
||||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
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
|
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
|
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
|
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
|
||||||
|
|
||||||
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
|
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
|
||||||
|
|
||||||
Two contracts follow from it.
|
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.
|
- **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).
|
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
|
||||||
|
|
||||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
|
||||||
|
|
||||||
## Client capability ownership
|
## Client capability ownership
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
+25
-10
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
|
|||||||
The invariants are:
|
The invariants are:
|
||||||
|
|
||||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||||
> agent establishes the daemon's current tail in one bounded request, with older history reachable
|
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||||
> through backward pagination.
|
> through backward pagination.
|
||||||
|
|
||||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||||
@@ -44,7 +44,21 @@ recovery is complete only when `hasNewer: false`.
|
|||||||
|
|
||||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||||
|
|
||||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
Opening or resuming an agent fetches the latest available turns with a soft target of 40
|
||||||
|
projected items. Backward pages and prompt-jump windows also include whole turns, including
|
||||||
|
all currently available rows of an active run. There is no per-turn cap: a large run can
|
||||||
|
exceed the target. Local history mounting uses the same boundaries so revealing older rows
|
||||||
|
does not change the collapse state of a turn already displayed.
|
||||||
|
|
||||||
|
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
|
||||||
|
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
|
||||||
|
Reading history remains available on older hosts, whose pages can still split turns. Forward
|
||||||
|
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
|
||||||
|
Source coverage still includes interleaved tool updates.
|
||||||
|
|
||||||
|
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||||
|
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||||
|
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||||
user-driven by scrolling upward.
|
user-driven by scrolling upward.
|
||||||
|
|
||||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||||
@@ -149,12 +163,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
|
|||||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
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.
|
history. Code outside the owner does not distinguish cached and network timelines.
|
||||||
|
|
||||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
|
||||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||||
|
|
||||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
|
||||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
range and keeps a display-only tail of complete available turns. The item target is soft;
|
||||||
|
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
|
||||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||||
|
|
||||||
@@ -238,11 +253,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
|||||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
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.
|
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||||
|
|
||||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
Every canonical run owns a visible turn, including heartbeats and agent notifications.
|
||||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
Notifications delivered during a run stay inside it; batched notifications share the run
|
||||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
that starts or accepts them. Received messages retain sender attribution, while lifecycle
|
||||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
notifications remain visible events. Collapse, timing and locally mounted history share
|
||||||
fork positions retain the canonical `turnId` boundaries.
|
these boundaries.
|
||||||
|
|
||||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
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
|
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||||
|
- Fixed the Android animation run loop continuing while the app is in the background
|
||||||
|
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||||
|
- Fixed bottom sheets retaining window-size listeners after closing
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||||
|
- Fixed the Android animation run loop continuing while the app is in the background
|
||||||
|
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||||
|
- Fixed bottom sheets retaining window-size listeners after closing
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||||
|
- Fixed the Android animation run loop continuing while the app is in the background
|
||||||
|
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||||
|
- Fixed bottom sheets retaining window-size listeners after closing
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Fixed
|
||||||
|
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||||
|
- Fixed the Android animation run loop continuing while the app is in the background
|
||||||
|
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||||
|
- Fixed bottom sheets retaining window-size listeners after closing
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
|
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
||||||
|
|||||||
Generated
+2357
-4098
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.13.1",
|
"version": "0.14.0-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -131,6 +131,7 @@
|
|||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
|
"expo-module-scripts": "^5.0.8",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||||
import {
|
import {
|
||||||
scrollTimelineToNewestLoadedEdge,
|
scrollTimelineToNewestLoadedEdge,
|
||||||
|
userScrollsTimelineToHistoryStart,
|
||||||
scrollTimelineUntilOlderHistoryIsReachable,
|
scrollTimelineUntilOlderHistoryIsReachable,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} from "../support/helpers/timeline-pagination";
|
||||||
import {
|
import {
|
||||||
@@ -986,7 +987,7 @@ test.describe("Agent message submission", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
|
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const workspace = await seedWorkspace({
|
const workspace = await seedWorkspace({
|
||||||
@@ -1003,8 +1004,10 @@ test.describe("Agent message submission", () => {
|
|||||||
await expectVisibleAgentSurfacesIdle(page);
|
await expectVisibleAgentSurfacesIdle(page);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(submittedPrompt).toHaveCount(0);
|
// The complete turn arrives on reload; scrolling mounts its oldest rows
|
||||||
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
|
// without requiring another page or an increase in total content height.
|
||||||
|
await userScrollsTimelineToHistoryStart(page);
|
||||||
|
await expect(submittedPrompt).toBeVisible();
|
||||||
await expect(submittedPrompt).toHaveCount(1);
|
await expect(submittedPrompt).toHaveCount(1);
|
||||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
seedRealisticOlderTimeline,
|
seedRealisticOlderTimeline,
|
||||||
sendLiveTurnBeforeHydration,
|
sendLiveTurnBeforeHydration,
|
||||||
expectTimelineViewportAnchoredAfterPrepend,
|
expectTimelineViewportAnchoredAfterPrepend,
|
||||||
|
expectToolCallGroupPositionPreserved,
|
||||||
|
rememberToolCallGroupPosition,
|
||||||
|
seedItemPagedToolGroupTimeline,
|
||||||
|
useChatDisplay,
|
||||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||||
userScrollsTimelineToHistoryStart,
|
userScrollsTimelineToHistoryStart,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} from "../support/helpers/timeline-pagination";
|
||||||
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hosts paged by items can end a page inside a tool run. The older page extends the group
|
||||||
|
// the reader is on, which changes the row hosting it; the reader must stay with that group
|
||||||
|
// rather than land at history start and drain every older page.
|
||||||
|
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await useChatDisplay(page, "collapseToolCalls");
|
||||||
|
const agent = await seedItemPagedToolGroupTimeline();
|
||||||
|
try {
|
||||||
|
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
|
||||||
|
await openAgentTimeline(page, agent);
|
||||||
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
|
|
||||||
|
await userScrollsTimelineToHistoryStart(page);
|
||||||
|
await history.expectRequestedPages(1);
|
||||||
|
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
|
||||||
|
|
||||||
|
history.releasePage(1);
|
||||||
|
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
|
||||||
|
await history.expectSettledWithRequestedPages(1);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("loads older history when the user navigates to history start with the keyboard", async ({
|
test("loads older history when the user navigates to history start with the keyboard", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
|
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
|
||||||
|
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
@@ -296,7 +297,186 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await repo.cleanup().catch(() => undefined);
|
await repo.cleanup().catch(() => undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
const corpus = await createPullRequestCorpusRepository();
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: corpus.repo.path },
|
||||||
});
|
});
|
||||||
|
if (!created.workspace) {
|
||||||
|
throw new Error(created.error ?? "Failed to create wrap performance workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
await configureUnwrappedUnifiedDiff(page);
|
||||||
|
await instrumentDiffLatency(page);
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
const openStartedAt = performance.now();
|
||||||
|
await openChangesPanel(page, 90_000);
|
||||||
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
const open = {
|
||||||
|
readyMs: Math.round(performance.now() - openStartedAt),
|
||||||
|
...(await readDiffDocumentRenders(page)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggles: WrapToggleMeasurement[] = [];
|
||||||
|
for (const name of [
|
||||||
|
"wrap on (cold)",
|
||||||
|
"wrap off (cold)",
|
||||||
|
"wrap on (warm)",
|
||||||
|
"wrap off (warm)",
|
||||||
|
]) {
|
||||||
|
toggles.push(await measureWrapToggle(page, name));
|
||||||
|
}
|
||||||
|
const unwrappedScroll = await continuouslyScrollCanvas(page);
|
||||||
|
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
|
||||||
|
const wrappedScroll = await continuouslyScrollCanvas(page);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
scenario: "wrap-toggle",
|
||||||
|
sourceFiles: corpus.fileCount,
|
||||||
|
sourceLines: corpus.lineCount,
|
||||||
|
open,
|
||||||
|
toggles,
|
||||||
|
scroll: {
|
||||||
|
unwrapped: summarizeScroll(unwrappedScroll),
|
||||||
|
wrapped: summarizeScroll(wrappedScroll),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await attachPerformanceReport(testInfo, report);
|
||||||
|
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
|
||||||
|
|
||||||
|
const [wrapOn, wrapOff] = toggles;
|
||||||
|
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
|
||||||
|
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await corpus.repo.cleanup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface WrapToggleMeasurement {
|
||||||
|
name: string;
|
||||||
|
/** Pointer down on the toolbar button to two painted frames after the document resized. */
|
||||||
|
latencyMs: number;
|
||||||
|
scrollHeightBefore: number;
|
||||||
|
scrollHeightAfter: number;
|
||||||
|
longestTaskMs: number;
|
||||||
|
longTaskTotalMs: number;
|
||||||
|
diffDocumentCommits: number;
|
||||||
|
diffDocumentRenderMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LongTask {
|
||||||
|
startTime: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffLatencyScope {
|
||||||
|
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
|
||||||
|
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
|
||||||
|
__PASEO_RESET_RENDER_PROFILE__?: () => void;
|
||||||
|
__PASEO_LONG_TASKS__?: LongTask[];
|
||||||
|
__PASEO_WRAP_TOGGLE_AT__?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPullRequestCorpusRepository() {
|
||||||
|
const files = pullRequestCorpus();
|
||||||
|
const repo = await createTempGitRepo("diff-wrap-perf-", {
|
||||||
|
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
|
||||||
|
});
|
||||||
|
for (const file of files) {
|
||||||
|
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
repo,
|
||||||
|
fileCount: files.length,
|
||||||
|
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instrumentDiffLatency(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||||
|
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||||
|
scope.__PASEO_LONG_TASKS__ = [];
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
|
||||||
|
}
|
||||||
|
}).observe({ type: "longtask", buffered: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDiffDocumentRenders(
|
||||||
|
page: Page,
|
||||||
|
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||||
|
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
|
||||||
|
(sample) => sample.id === "DiffDocument",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
diffDocumentCommits: samples.length,
|
||||||
|
diffDocumentRenderMs: Math.round(
|
||||||
|
samples.reduce((total, sample) => total + sample.actualDuration, 0),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
|
||||||
|
const scroll = page.getByTestId("git-diff-scroll");
|
||||||
|
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||||
|
scope.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||||
|
scope.__PASEO_LONG_TASKS__ = [];
|
||||||
|
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
|
||||||
|
document.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
() => {
|
||||||
|
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
|
||||||
|
},
|
||||||
|
{ capture: true, once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.getByTestId("changes-toggle-wrap-lines").click();
|
||||||
|
const measured = await scroll.evaluate(async (element, before) => {
|
||||||
|
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||||
|
const deadline = performance.now() + 240_000;
|
||||||
|
while (element.scrollHeight === before && performance.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
);
|
||||||
|
const doneAt = performance.now();
|
||||||
|
// Long task entries are delivered after the task ends; give the observer a turn.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
|
||||||
|
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
|
||||||
|
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
latencyMs: Math.round(doneAt - startedAt),
|
||||||
|
scrollHeightAfter: element.scrollHeight,
|
||||||
|
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
|
||||||
|
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
|
||||||
|
};
|
||||||
|
}, scrollHeightBefore);
|
||||||
|
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
|
||||||
|
const { frameSignatures: _frameSignatures, ...summary } = report;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
async function measureAdditionalPerformanceScenario(
|
async function measureAdditionalPerformanceScenario(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|||||||
@@ -1,29 +1,90 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
|
|
||||||
import {
|
import {
|
||||||
focusTerminalSurface,
|
focusTerminalSurface,
|
||||||
typeInTerminal,
|
typeInTerminal,
|
||||||
getTerminalBufferText,
|
getTerminalBufferText,
|
||||||
} from "../support/helpers/terminal-perf";
|
} from "../support/helpers/terminal-perf";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
import { metroTest as test, expect } from "../support/fixtures";
|
import { metroTest as test, expect, type Page } from "../support/fixtures";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
||||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import {
|
import {
|
||||||
expectTimelinePromptVisible,
|
expectFoldedTurnAtReadingPosition,
|
||||||
|
expectOldestLoadedTurnPartial,
|
||||||
holdOlderHistoryPages,
|
holdOlderHistoryPages,
|
||||||
openAgentTimeline,
|
openAgentTimeline,
|
||||||
scrollThroughOlderHistoryPages,
|
expectTimelinePromptVisible,
|
||||||
|
userScrollsTimelineToHistoryStart,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} from "../support/helpers/timeline-pagination";
|
||||||
|
|
||||||
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
|
interface PublishedDaemonAgent {
|
||||||
|
serverId: string;
|
||||||
|
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||||
|
agent: MockAgentWorkspace;
|
||||||
|
/** Register the host in the browser and apply app settings before the first load. */
|
||||||
|
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPublishedDaemonAgent(options: {
|
||||||
|
publishedVersion: string;
|
||||||
|
serverIdPrefix: string;
|
||||||
|
repoPrefix: string;
|
||||||
|
title: string;
|
||||||
|
}): Promise<PublishedDaemonAgent> {
|
||||||
|
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||||
|
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||||
|
publishedVersion: options.publishedVersion,
|
||||||
|
});
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
|
||||||
|
const createdAgent = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: options.title,
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
});
|
||||||
|
const agent: MockAgentWorkspace = {
|
||||||
|
agentId: createdAgent.id,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
client: workspace.client,
|
||||||
|
cleanup: workspace.cleanup,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
daemon,
|
||||||
|
agent,
|
||||||
|
async seedBrowser(page, appSettings) {
|
||||||
|
const host = buildSeededHost({
|
||||||
|
serverId,
|
||||||
|
endpoint: `127.0.0.1:${daemon.port}`,
|
||||||
|
nowIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await page.addInitScript(
|
||||||
|
({ seededHost, preferences, settings }) => {
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
|
||||||
|
},
|
||||||
|
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
await agent.cleanup();
|
||||||
|
await daemon.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(120_000);
|
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>();
|
const observationSockets = new Set<unknown>();
|
||||||
page.on("websocket", (socket) => {
|
page.on("websocket", (socket) => {
|
||||||
socket.on("framesent", ({ payload }) => {
|
socket.on("framesent", ({ payload }) => {
|
||||||
@@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis
|
|||||||
observationSockets.add(socket);
|
observationSockets.add(socket);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const workspace = await seedWorkspace({
|
const published = await startPublishedDaemonAgent({
|
||||||
|
publishedVersion: "0.2.5",
|
||||||
|
serverIdPrefix: "srv_old_pagination_",
|
||||||
repoPrefix: "timeline-old-daemon-pagination-",
|
repoPrefix: "timeline-old-daemon-pagination-",
|
||||||
port: daemon.port,
|
|
||||||
});
|
|
||||||
const createdAgent = await workspace.client.createAgent({
|
|
||||||
provider: "mock",
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
title: "Published daemon pagination regression",
|
title: "Published daemon pagination regression",
|
||||||
modeId: "load-test",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
});
|
});
|
||||||
const agent: MockAgentWorkspace = {
|
const { agent, serverId } = published;
|
||||||
agentId: createdAgent.id,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
client: workspace.client,
|
|
||||||
cleanup: workspace.cleanup,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < 40; index += 1) {
|
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||||
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.waitForFinish(agent.agentId, 15_000);
|
||||||
}
|
await published.seedBrowser(page);
|
||||||
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 openAgentTimeline(page, agent, serverId);
|
||||||
await expectTimelinePromptVisible(
|
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||||
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);
|
expect(observationSockets.size).toBe(1);
|
||||||
|
|
||||||
await test.step("send a live turn after reconnecting the old daemon", async () => {
|
await test.step("reconnect the browser and reload the old daemon history", async () => {
|
||||||
await daemon.restart();
|
observationSockets.clear();
|
||||||
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
|
await page.reload();
|
||||||
await expectComposerEditable(page);
|
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||||
await submitMessage(
|
await expect.poll(() => observationSockets.size).toBe(1);
|
||||||
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 () => {
|
await test.step("open a terminal and receive its output", async () => {
|
||||||
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
||||||
expect(terminal.error).toBeNull();
|
expect(terminal.error).toBeNull();
|
||||||
const terminalId = terminal.terminal!.id;
|
const terminalId = terminal.terminal!.id;
|
||||||
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
|
||||||
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
||||||
await focusTerminalSurface(page);
|
await focusTerminalSurface(page);
|
||||||
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
||||||
await expect
|
await expect
|
||||||
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
|
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
|
||||||
.toContain("compatibility-terminal-output");
|
.toContain("compatibility-terminal-output");
|
||||||
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
|
|
||||||
await agent.client.killTerminal(terminalId);
|
await agent.client.killTerminal(terminalId);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await agent.cleanup();
|
await published.close();
|
||||||
await daemon.close();
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
|
||||||
|
// starts mid-turn and only folds once the next page completes it; the reader must stay
|
||||||
|
// with that turn instead of landing at history start and draining every older page.
|
||||||
|
test("keeps the reader on the turn that folds when an older item page completes it", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const published = await startPublishedDaemonAgent({
|
||||||
|
publishedVersion: "0.13.0-beta.2",
|
||||||
|
serverIdPrefix: "srv_old_fold_",
|
||||||
|
repoPrefix: "timeline-old-daemon-fold-",
|
||||||
|
title: "Published daemon folded turn pagination",
|
||||||
|
});
|
||||||
|
const { agent, serverId } = published;
|
||||||
|
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
|
}
|
||||||
|
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
|
||||||
|
const history = await holdOlderHistoryPages(page, agent, {
|
||||||
|
daemonPort: published.daemon.port,
|
||||||
|
});
|
||||||
|
await openAgentTimeline(page, agent, serverId);
|
||||||
|
await expectTimelinePromptVisible(page, prompts.at(-1)!);
|
||||||
|
|
||||||
|
await userScrollsTimelineToHistoryStart(page);
|
||||||
|
await history.expectRequestedPages(1);
|
||||||
|
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
|
||||||
|
|
||||||
|
history.releasePage(1);
|
||||||
|
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
|
||||||
|
await history.expectSettledWithRequestedPages(1);
|
||||||
|
} finally {
|
||||||
|
await published.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import {
|
import {
|
||||||
withTimelinePlugin,
|
withTimelinePlugin,
|
||||||
viewInlineThinkingExample,
|
viewInlineThinkingExample,
|
||||||
@@ -6,6 +7,8 @@ import {
|
|||||||
interactWithStreamingCard,
|
interactWithStreamingCard,
|
||||||
expectWholeCompletedCard,
|
expectWholeCompletedCard,
|
||||||
expectBothConsecutiveTools,
|
expectBothConsecutiveTools,
|
||||||
|
trackPluginChatRecovery,
|
||||||
|
reconnectPluginChat,
|
||||||
} from "../support/helpers/plugin-timeline";
|
} from "../support/helpers/plugin-timeline";
|
||||||
|
|
||||||
for (const width of [1100, 390]) {
|
for (const width of [1100, 390]) {
|
||||||
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
|
|||||||
page,
|
page,
|
||||||
}, info) => {
|
}, info) => {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||||
await requestPluginTimeline(agent);
|
await requestPluginTimeline(agent);
|
||||||
await interactWithStreamingCard(page);
|
await interactWithStreamingCard(page);
|
||||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
await expectWholeCompletedCard(page);
|
await expectWholeCompletedCard(page);
|
||||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||||
|
const recovery = trackPluginChatRecovery(page);
|
||||||
|
gate.holdTimelineResponses(agent.agentId);
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await gate.waitForHeldTimelineResponse();
|
||||||
|
await recovery.expectCatalogWithoutRetransmission();
|
||||||
|
gate.releaseHeldTimelineResponses();
|
||||||
|
await expectWholeCompletedCard(page);
|
||||||
|
await recovery.expectRecoveredWithoutRetransmission();
|
||||||
|
recovery.reset();
|
||||||
|
await reconnectPluginChat(page, gate);
|
||||||
|
await recovery.expectRecoveredWithoutRetransmission();
|
||||||
await expectWholeCompletedCard(page);
|
await expectWholeCompletedCard(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import path from "node:path";
|
||||||
import type { Locator } from "@playwright/test";
|
import type { Locator } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewChat(page);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.getByRole("button", { name: /Select model/ }).click();
|
||||||
|
if (width < 600) {
|
||||||
|
await page.getByTestId("agent-controls-model").click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||||
|
}
|
||||||
|
await page.getByTestId("model-provider-picker-timeout").click();
|
||||||
|
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectErrorHasSideGutters(page: Page) {
|
||||||
|
const error = page.getByText(/Timed out refreshing Pi timeout/);
|
||||||
|
const bounds = await error.evaluate((element) => {
|
||||||
|
const text = element.getBoundingClientRect();
|
||||||
|
const state = element.parentElement!.getBoundingClientRect();
|
||||||
|
return { left: text.left - state.left, right: state.right - text.right };
|
||||||
|
});
|
||||||
|
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
|
||||||
|
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
e2eDaemonConfig: {
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
catalogRefreshTimeoutMs: 2000,
|
||||||
|
providers: {
|
||||||
|
"picker-timeout": {
|
||||||
|
extends: "pi",
|
||||||
|
label: "Pi timeout",
|
||||||
|
enabled: true,
|
||||||
|
command: [
|
||||||
|
process.execPath,
|
||||||
|
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
|
||||||
|
"--stall-model-discovery",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("model discovery timeout", () => {
|
||||||
|
for (const width of [1200, 390]) {
|
||||||
|
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
|
||||||
|
await openTimedOutProvider(page, workspace.workspaceId, width);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
|
||||||
|
await expectErrorHasSideGutters(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||||
@@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function selectSendingOption(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
setting: string,
|
||||||
|
option: string,
|
||||||
|
) {
|
||||||
|
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
|
||||||
|
await page.getByRole("menuitem", { name: option, exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDraftWithShortcut(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
draft: string,
|
||||||
|
shortcut: string,
|
||||||
|
) {
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await composer.fill(draft);
|
||||||
|
await composer.press(shortcut);
|
||||||
|
await expect(composer).toHaveValue("");
|
||||||
|
await expect(page.getByText(draft, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
|
||||||
|
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
|
||||||
|
try {
|
||||||
|
const agent = await createMockIdleAgent(workspace.client, {
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: "Send shortcut",
|
||||||
|
});
|
||||||
|
await openAgentRouteAndExpectFocused({
|
||||||
|
page,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: agent.id,
|
||||||
|
});
|
||||||
|
await openComposerTab(page, agent.id);
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
|
||||||
|
await test.step("Enter sends by default", async () => {
|
||||||
|
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
|
||||||
|
});
|
||||||
|
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
|
||||||
|
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
|
||||||
|
// Let the menu opening animation settle for visual QA.
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
|
||||||
|
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(composer).toBeVisible();
|
||||||
|
});
|
||||||
|
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
|
||||||
|
await composer.fill("First line");
|
||||||
|
await composer.press("Enter");
|
||||||
|
await expect(composer).toHaveValue("First line\n");
|
||||||
|
await composer.press("Shift+Enter");
|
||||||
|
await expect(composer).toHaveValue("First line\n\n");
|
||||||
|
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
|
||||||
|
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
|
||||||
|
});
|
||||||
|
await test.step("switch back to Enter without changing the default send behavior", async () => {
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await selectSendingOption(page, "Send shortcut", "Enter");
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await composer.fill("Shift still edits");
|
||||||
|
await composer.press("Shift+Enter");
|
||||||
|
await expect(composer).toHaveValue("Shift still edits\n");
|
||||||
|
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
|
||||||
|
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "running-send-shortcut-",
|
||||||
|
title: "Running send shortcut",
|
||||||
|
model: "one-minute-stream",
|
||||||
|
});
|
||||||
|
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
|
||||||
|
await selectSendingOption(page, "Default send", behavior);
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
|
||||||
|
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await composer.fill("Follow-up");
|
||||||
|
await composer.press("Enter");
|
||||||
|
await expect(composer).toHaveValue("Follow-up\n");
|
||||||
|
await composer.press("Control+Enter");
|
||||||
|
await expect(composer).toHaveValue("");
|
||||||
|
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
|
||||||
|
behavior === "Queue" ? 1 : 0,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
holdOlderHistoryPages,
|
||||||
|
userScrollsTimelineToHistoryStart,
|
||||||
|
} from "../support/helpers/timeline-pagination";
|
||||||
|
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||||
|
import type { Page, Locator } from "@playwright/test";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
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";
|
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||||
|
|
||||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||||
@@ -19,7 +40,10 @@ async function seedAnsweredAgent(title: string) {
|
|||||||
initialPrompt: "Why does the chat stop following new output?",
|
initialPrompt: "Why does the chat stop following new output?",
|
||||||
});
|
});
|
||||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
|
await agent.client.sendAgentMessage(
|
||||||
|
agent.agentId,
|
||||||
|
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
|
||||||
|
);
|
||||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
@@ -36,17 +60,84 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||||
|
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||||
|
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
|
||||||
|
return rows
|
||||||
|
.slice(start + 1, end)
|
||||||
|
.flatMap((row) =>
|
||||||
|
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
|
||||||
|
(group) => group.textContent ?? "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCompactMessageToggle(prompt: Locator, label: string) {
|
||||||
|
const button = prompt.getByRole("button", { name: label, exact: true });
|
||||||
|
const text = prompt.getByText(label, { exact: true });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const control = (await button.boundingBox())!;
|
||||||
|
const glyphs = (await text.boundingBox())!;
|
||||||
|
return control.height - glyphs.height;
|
||||||
|
},
|
||||||
|
{ message: "The inline toggle adds no vertical space beyond its text" },
|
||||||
|
)
|
||||||
|
.toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expandAndCollapseLongPrompt(page: Page) {
|
||||||
|
const prompt = page.getByTestId("user-message").last();
|
||||||
|
const before = await prompt.boundingBox();
|
||||||
|
const text = prompt.locator('[data-message-text="true"]');
|
||||||
|
const messageLeft = (await text.boundingBox())!.x;
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
|
||||||
|
.toBe(messageLeft);
|
||||||
|
await expectCompactMessageToggle(prompt, "Show more");
|
||||||
|
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
|
||||||
|
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
|
||||||
|
.toBe(messageLeft);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
|
||||||
|
.toBeGreaterThan(before!.height);
|
||||||
|
await expectCompactMessageToggle(prompt, "Show less");
|
||||||
|
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
|
||||||
|
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
|
||||||
|
await prompt.scrollIntoViewIfNeeded();
|
||||||
|
const bounds = (await prompt.boundingBox())!;
|
||||||
|
const body = (await text.boundingBox())!;
|
||||||
|
await page.screenshot({
|
||||||
|
path: "/tmp/message-toggle-aligned.png",
|
||||||
|
clip: {
|
||||||
|
x: body.x - 16,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.x + bounds.width - body.x + 16,
|
||||||
|
height: bounds.height,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Turn collapse", () => {
|
test.describe("Turn collapse", () => {
|
||||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||||
await useChatDisplay(page, "collapseTurns");
|
await useChatDisplay(page, "collapseTurns");
|
||||||
const agent = await seedAnsweredAgent("Turn collapse");
|
const agent = await seedAnsweredAgent("Turn collapse");
|
||||||
try {
|
try {
|
||||||
await openAgentRoute(page, agent);
|
await openAgentRoute(page, agent);
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
|
|
||||||
|
await expandAndCollapseLongPrompt(page);
|
||||||
|
|
||||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||||
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
|
await expect(summary).toHaveText(/^Edited 1 file/);
|
||||||
// Text chunks that stream back to back merge into one message, so the reply holds
|
// Text chunks that stream back to back merge into one message, so the reply holds
|
||||||
// at least the stream's last chunk.
|
// at least the stream's last chunk.
|
||||||
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
||||||
@@ -57,6 +148,9 @@ test.describe("Turn collapse", () => {
|
|||||||
await summary.click();
|
await summary.click();
|
||||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||||
await expect(summary).toBeInViewport();
|
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 summary.click();
|
||||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||||
@@ -65,3 +159,400 @@ test.describe("Turn collapse", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("received messages show their sender and open that agent", async ({ page }) => {
|
||||||
|
await page.addInitScript(() =>
|
||||||
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
|
||||||
|
);
|
||||||
|
const recipient = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "sender-attribution-",
|
||||||
|
title: "Implementation",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const sender = await recipient.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: recipient.cwd,
|
||||||
|
workspaceId: recipient.workspaceId,
|
||||||
|
title: "Reviewer",
|
||||||
|
model: "gpt-6.1-sol",
|
||||||
|
modeId: "full-access",
|
||||||
|
});
|
||||||
|
await recipient.client.sendAgentMessage(
|
||||||
|
recipient.agentId,
|
||||||
|
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
|
||||||
|
{ sourceAgentId: sender.id },
|
||||||
|
);
|
||||||
|
await openAgentRoute(page, recipient);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
|
||||||
|
await expect(attribution).toBeVisible();
|
||||||
|
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
|
||||||
|
"I reviewed the turn-grouping changes.",
|
||||||
|
);
|
||||||
|
const labelColor = await attribution
|
||||||
|
.getByText("Sent by Reviewer", { exact: true })
|
||||||
|
.evaluate((element) => getComputedStyle(element).color);
|
||||||
|
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
|
||||||
|
await expectSenderAlignedWithBubble(attribution);
|
||||||
|
await attribution.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: "/tmp/sender-attribution.png" });
|
||||||
|
await attribution.click();
|
||||||
|
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
|
||||||
|
} finally {
|
||||||
|
await recipient.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MessagePreviewWatch {
|
||||||
|
/** Frames after the watch started on which the fade or "Show more" control was missing. */
|
||||||
|
framesWithoutClip: number[];
|
||||||
|
/** Frames on which the fade was a different DOM node than when the watch started. */
|
||||||
|
framesRemounted: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedReceivedReviewWithToolTurn() {
|
||||||
|
const recipient = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "preview-clip-",
|
||||||
|
title: "Implementation",
|
||||||
|
});
|
||||||
|
const sender = await recipient.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: recipient.cwd,
|
||||||
|
workspaceId: recipient.workspaceId,
|
||||||
|
title: "Reviewer",
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "e2e-fast-stream",
|
||||||
|
});
|
||||||
|
// Long enough to clip behind the preview fade; the instruction makes the recipient run tools.
|
||||||
|
await recipient.client.sendAgentMessage(
|
||||||
|
recipient.agentId,
|
||||||
|
"Review complete: the regression is red on the baseline and green with the fix.\n" +
|
||||||
|
"Keep the previous response visible while the reader scrolls.\n".repeat(30) +
|
||||||
|
"Now emit 7 tool agent stream updates",
|
||||||
|
{ sourceAgentId: sender.id },
|
||||||
|
);
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples the received message's bubble on every animation frame until stopped. */
|
||||||
|
async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise<void> {
|
||||||
|
await page.evaluate((label) => {
|
||||||
|
const findBubble = () =>
|
||||||
|
[...document.querySelectorAll("[data-testid=user-message]")].find((row) =>
|
||||||
|
row.textContent?.includes(label),
|
||||||
|
) ?? null;
|
||||||
|
const initialFade = findBubble()?.querySelector("svg") ?? null;
|
||||||
|
if (!initialFade) throw new Error(`No faded bubble for ${label}`);
|
||||||
|
const framesWithoutClip: number[] = [];
|
||||||
|
const framesRemounted: number[] = [];
|
||||||
|
let frame = 0;
|
||||||
|
let running = true;
|
||||||
|
const sample = () => {
|
||||||
|
if (!running) return;
|
||||||
|
const bubble = findBubble();
|
||||||
|
const fade = bubble?.querySelector("svg") ?? null;
|
||||||
|
if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame);
|
||||||
|
else if (fade !== initialFade) framesRemounted.push(frame);
|
||||||
|
frame += 1;
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
window.__paseoMessagePreviewWatch = {
|
||||||
|
stop() {
|
||||||
|
running = false;
|
||||||
|
return { framesWithoutClip, framesRemounted };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, senderLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise<void> {
|
||||||
|
// Give the opened turn's layout a few frames to settle before judging.
|
||||||
|
await page.evaluate(
|
||||||
|
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||||
|
);
|
||||||
|
const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop());
|
||||||
|
expect(
|
||||||
|
watch.framesWithoutClip,
|
||||||
|
"the preview fade and Show more control stay on every frame",
|
||||||
|
).toEqual([]);
|
||||||
|
expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening a turn must not rebuild its host row: the preview above the summary loses its
|
||||||
|
// measured height when remounted, which drops the fade and "Show more" for a frame.
|
||||||
|
test("opening a turn keeps the received message above it clipped on every frame", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await useChatDisplay(page, "collapseTurns");
|
||||||
|
const recipient = await seedReceivedReviewWithToolTurn();
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, recipient);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const bubble = page
|
||||||
|
.getByTestId("user-message")
|
||||||
|
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
|
||||||
|
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
|
||||||
|
await bubble.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
|
await watchReceivedMessagePreview(page, "Sent by Reviewer");
|
||||||
|
await page
|
||||||
|
.getByTestId("turn-summary")
|
||||||
|
.getByRole("button", { name: "Ran 7 commands", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible();
|
||||||
|
await expectReceivedMessagePreviewUndisturbed(page);
|
||||||
|
} finally {
|
||||||
|
await recipient.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expectSenderAlignedWithBubble(attribution: Locator) {
|
||||||
|
const edges = await attribution.evaluate((button) => {
|
||||||
|
const bubble = button.nextElementSibling;
|
||||||
|
const label = button.querySelector('[dir="auto"]');
|
||||||
|
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
|
||||||
|
return {
|
||||||
|
labelRight: label.getBoundingClientRect().right,
|
||||||
|
bubbleRight: bubble.getBoundingClientRect().right,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
Math.abs(edges.labelRight - edges.bubbleRight),
|
||||||
|
"sender and bubble share the right edge",
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedWholeTurnHistory() {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "whole-turn-history-",
|
||||||
|
title: "Whole turns",
|
||||||
|
model: "e2e-fast-stream",
|
||||||
|
});
|
||||||
|
for (const prompt of [
|
||||||
|
...Array.from(
|
||||||
|
{ length: 25 },
|
||||||
|
(_, index) => `Short ${index}: emit 1 coalesced agent stream updates`,
|
||||||
|
),
|
||||||
|
"Older long turn: emit 60 tool agent stream updates",
|
||||||
|
"Latest long turn: emit 120 tool agent stream updates",
|
||||||
|
]) {
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await useChatDisplay(page, "collapseTurns");
|
||||||
|
const agent = await seedWholeTurnHistory();
|
||||||
|
try {
|
||||||
|
const history = await holdOlderHistoryPages(page, agent);
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
const latest = page
|
||||||
|
.getByTestId("turn-summary")
|
||||||
|
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||||
|
await expect(latest).toBeVisible();
|
||||||
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||||
|
await latest.click();
|
||||||
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||||
|
await userScrollsTimelineToHistoryStart(page);
|
||||||
|
await history.expectRequestedPages(1);
|
||||||
|
history.releasePage(1);
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId("turn-summary")
|
||||||
|
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||||
|
history.expectNoRepeatedEntries();
|
||||||
|
await latest.scrollIntoViewIfNeeded();
|
||||||
|
await latest.click();
|
||||||
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function useThinkingDisplay(
|
||||||
|
page: Page,
|
||||||
|
autoExpandReasoning: boolean,
|
||||||
|
chatDisplay: ChatDisplay,
|
||||||
|
) {
|
||||||
|
await page.addInitScript(
|
||||||
|
(settings) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"@paseo:app-settings",
|
||||||
|
JSON.stringify({
|
||||||
|
...settings,
|
||||||
|
chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ autoExpandReasoning, chatDisplay },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const autoExpandReasoning of [false, true]) {
|
||||||
|
test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||||
|
await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls");
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "thinking-group-",
|
||||||
|
title: "Thinking with tools",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
initialPrompt: "Inspect the scroll container",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
const reasoning = page
|
||||||
|
.getByText(/Need to find the scroll container, the layout effect/)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expectThinkingPresentation(page, autoExpandReasoning);
|
||||||
|
await switchToCollapsedTurns(page);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||||
|
await expect(summary).toBeVisible();
|
||||||
|
await expect(summary).not.toContainText(/thought/i);
|
||||||
|
await expect(reasoning).toHaveCount(0);
|
||||||
|
await summary.click();
|
||||||
|
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
|
||||||
|
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
|
||||||
|
await openOneToolOutput(page);
|
||||||
|
await summary.click();
|
||||||
|
await expect(reasoning).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openOneToolOutput(page: Page) {
|
||||||
|
const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true });
|
||||||
|
await expect(output).toHaveCount(0);
|
||||||
|
const command = page
|
||||||
|
.getByTestId("tool-call-badge")
|
||||||
|
.filter({ hasText: "node scripts/simulate-stream-burst.mjs" })
|
||||||
|
.first();
|
||||||
|
await command.getByRole("button").first().click();
|
||||||
|
await expect(output.first()).toBeVisible();
|
||||||
|
await command.getByRole("button").first().click();
|
||||||
|
await expect(output).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) {
|
||||||
|
const reasoning = page
|
||||||
|
.getByText(/Need to find the scroll container, the layout effect/)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
if (autoExpandReasoning) {
|
||||||
|
await expect(reasoning).toBeVisible();
|
||||||
|
await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0);
|
||||||
|
} else {
|
||||||
|
await expect(reasoning).toHaveCount(0);
|
||||||
|
const group = page
|
||||||
|
.getByTestId("tool-call-group")
|
||||||
|
.filter({ hasText: /read 1 file/i, visible: true })
|
||||||
|
.last();
|
||||||
|
await expect(group).toContainText(/read 1 file/i);
|
||||||
|
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||||
|
await group.getByRole("button").first().click();
|
||||||
|
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||||
|
// until the reader opens it.
|
||||||
|
const thought = group
|
||||||
|
.getByTestId("tool-call-badge")
|
||||||
|
.filter({ hasText: /^Thinking/ })
|
||||||
|
.first();
|
||||||
|
await expect(thought).toBeVisible();
|
||||||
|
await expect(reasoning).toHaveCount(0);
|
||||||
|
// A press right after the group opens occasionally does not register; retry until the
|
||||||
|
// thought row opens.
|
||||||
|
await expect(async () => {
|
||||||
|
await thought.getByRole("button").first().click();
|
||||||
|
await expect(reasoning).toBeVisible({ timeout: 2_000 });
|
||||||
|
}).toPass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchToCollapsedTurns(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await useChatDisplay(page, "collapseTurns");
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "heartbeat-turn-",
|
||||||
|
title: "Heartbeat turns",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
initialPrompt: "Inspect the scroll container",
|
||||||
|
});
|
||||||
|
const schedules = agent.client as unknown as Pick<
|
||||||
|
DaemonClient,
|
||||||
|
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
|
||||||
|
>;
|
||||||
|
let scheduleId: string | undefined;
|
||||||
|
try {
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
const summaries = page.getByTestId("turn-summary");
|
||||||
|
await expect(summaries).toHaveCount(1);
|
||||||
|
const previousSummary = await summaries.first().innerText();
|
||||||
|
const previousReplyId = await page
|
||||||
|
.getByTestId("assistant-message")
|
||||||
|
.last()
|
||||||
|
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
|
||||||
|
const created = await schedules.scheduleCreate({
|
||||||
|
name: "Check the completed work",
|
||||||
|
prompt: "Inspect the scroll container again",
|
||||||
|
cadence: { type: "cron", expression: "0 0 1 1 *" },
|
||||||
|
target: { type: "self", agentId: agent.agentId },
|
||||||
|
runOnCreate: false,
|
||||||
|
});
|
||||||
|
expect(created.error).toBeNull();
|
||||||
|
scheduleId = created.schedule!.id;
|
||||||
|
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
|
||||||
|
expect(fired.error).toBeNull();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(summaries.first()).toHaveText(previousSummary);
|
||||||
|
const previousReply = page
|
||||||
|
.locator(`[data-message-id="${previousReplyId}"]`)
|
||||||
|
.getByTestId("assistant-message");
|
||||||
|
await expect(previousReply).toBeVisible();
|
||||||
|
await expect(previousReply).toContainText("(end of synthetic stream)");
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
|
await expect(summaries).toHaveCount(2);
|
||||||
|
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||||
|
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
|
||||||
|
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
|
||||||
|
} finally {
|
||||||
|
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
expectWorkspaceTabVisible,
|
expectWorkspaceTabVisible,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
} from "../support/helpers/archive-tab";
|
} from "../support/helpers/archive-tab";
|
||||||
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
|
import {
|
||||||
|
expectAgentAbsentFromWorkspaceLayout,
|
||||||
|
seedForeignPinnedAgentTab,
|
||||||
|
} from "../support/helpers/workspace-layout";
|
||||||
import { clickNewChat } from "../support/helpers/launcher";
|
import { clickNewChat } from "../support/helpers/launcher";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
|
|||||||
test.describe("Workspace navigation regression", () => {
|
test.describe("Workspace navigation regression", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
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 ({
|
test("keeps an offline notification on the agent route until the host connects", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ for await (const line of lines) {
|
|||||||
const command = JSON.parse(line);
|
const command = JSON.parse(line);
|
||||||
let data = {};
|
let data = {};
|
||||||
if (command.type === "get_available_models") {
|
if (command.type === "get_available_models") {
|
||||||
|
if (process.argv.includes("--stall-model-discovery")) continue;
|
||||||
data = {
|
data = {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
|
|||||||
return delayAgentTimelineResponse(page, agentId, "before");
|
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(
|
export async function holdAgentOlderTimelinePages(
|
||||||
page: Page,
|
page: Page,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
daemonPort?: number,
|
options: OlderTimelinePagesOptions = {},
|
||||||
): Promise<OlderTimelinePagesGate> {
|
): Promise<OlderTimelinePagesGate> {
|
||||||
|
const { daemonPort } = options;
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let responseCount = 0;
|
let responseCount = 0;
|
||||||
let repeatedEntryCount = 0;
|
let repeatedEntryCount = 0;
|
||||||
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
|
|||||||
}
|
}
|
||||||
server.send(message);
|
server.send(message);
|
||||||
});
|
});
|
||||||
server.onMessage((message) => {
|
server.onMessage((received) => {
|
||||||
|
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
|
||||||
const sessionMessage = getSessionMessage(message);
|
const sessionMessage = getSessionMessage(message);
|
||||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
|||||||
.getByRole("button", { name: "Add", exact: true })
|
.getByRole("button", { name: "Add", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
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> {
|
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||||
|
|||||||
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
|||||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||||
|
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||||
|
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||||
|
|
||||||
const PLUGIN_ID = "timeline-transform-e2e";
|
const PLUGIN_ID = "timeline-transform-e2e";
|
||||||
|
|
||||||
|
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||||
|
export function trackPluginChatRecovery(page: Page) {
|
||||||
|
const messages: {
|
||||||
|
type: string;
|
||||||
|
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||||
|
}[] = [];
|
||||||
|
page.on("websocket", (socket) => {
|
||||||
|
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||||
|
if (typeof payload !== "string") return;
|
||||||
|
const frame = JSON.parse(payload);
|
||||||
|
if (frame.type === "session") messages.push(frame.message);
|
||||||
|
};
|
||||||
|
socket.on("framesent", collect);
|
||||||
|
socket.on("framereceived", collect);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reset() {
|
||||||
|
messages.length = 0;
|
||||||
|
},
|
||||||
|
async expectCatalogWithoutRetransmission() {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.type === "plugin.catalog.get.response" &&
|
||||||
|
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
const entry = messages
|
||||||
|
.find((message) => message.type === "plugin.catalog.get.response")
|
||||||
|
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry).not.toHaveProperty("clientBundle");
|
||||||
|
},
|
||||||
|
async expectRecoveredWithoutRetransmission() {
|
||||||
|
await this.expectCatalogWithoutRetransmission();
|
||||||
|
await expect
|
||||||
|
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||||
|
.toBe(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconnectPluginChat(
|
||||||
|
page: Page,
|
||||||
|
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||||
|
) {
|
||||||
|
await gate.drop();
|
||||||
|
await gate.waitForBlockedConnection();
|
||||||
|
await expectReconnectingToastVisible(page);
|
||||||
|
gate.restore();
|
||||||
|
}
|
||||||
export const ASSISTANT_TEXT =
|
export const ASSISTANT_TEXT =
|
||||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,11 @@ export interface SeedDaemonClient {
|
|||||||
timeout?: number,
|
timeout?: number,
|
||||||
): Promise<{ status: string }>;
|
): Promise<{ status: string }>;
|
||||||
cancelAgent(agentId: string): Promise<void>;
|
cancelAgent(agentId: string): Promise<void>;
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
sendAgentMessage(
|
||||||
|
agentId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { sourceAgentId?: string },
|
||||||
|
): Promise<void>;
|
||||||
waitForFinish(
|
waitForFinish(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, type TestInfo, type Page } from "@playwright/test";
|
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
|
||||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||||
import { readReplicaCache } from "./replica-cache-storage";
|
import { readReplicaCache } from "./replica-cache-storage";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type AgentTimelineResponseGate,
|
type AgentTimelineResponseGate,
|
||||||
type BootstrapTimelineGate,
|
type BootstrapTimelineGate,
|
||||||
type OlderTimelinePagesGate,
|
type OlderTimelinePagesGate,
|
||||||
|
type OlderTimelinePagesOptions,
|
||||||
} from "./agent-timeline-gate";
|
} from "./agent-timeline-gate";
|
||||||
|
|
||||||
export { holdDaemonHydration };
|
export { holdDaemonHydration };
|
||||||
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
|||||||
newestPrompt: string;
|
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 PROMPT_PREFIX = "timeline-pagination-turn";
|
||||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||||
const HISTORY_START_THRESHOLD_PX = 96;
|
const HISTORY_START_THRESHOLD_PX = 96;
|
||||||
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run of commands longer than an item page, followed by enough short turns that the
|
||||||
|
* initial tail ends inside the run. Read with item pages, the tail loads the newest
|
||||||
|
* commands as one group; the older page carries the rest of that run and the turns before
|
||||||
|
* it, with a further page behind it.
|
||||||
|
*/
|
||||||
|
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "timeline-item-page-tool-group-",
|
||||||
|
title: "Item page tool group regression",
|
||||||
|
});
|
||||||
|
const olderTurns = 10;
|
||||||
|
const newerTurns = 12;
|
||||||
|
const totalCommands = 50;
|
||||||
|
// A short turn is its prompt and one reply; the run's turn ends with one reply.
|
||||||
|
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
|
||||||
|
const shortTurnPrompt = (label: string, index: number) =>
|
||||||
|
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
|
||||||
|
const sendTurn = async (prompt: string) => {
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < olderTurns; index += 1) {
|
||||||
|
await sendTurn(shortTurnPrompt("older", index));
|
||||||
|
}
|
||||||
|
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
|
||||||
|
for (let index = 0; index < newerTurns; index += 1) {
|
||||||
|
await sendTurn(shortTurnPrompt("newer", index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
|
||||||
|
totalCommands,
|
||||||
|
loadedCommands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function useChatDisplay(
|
||||||
|
page: Page,
|
||||||
|
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
|
||||||
|
): Promise<void> {
|
||||||
|
await page.addInitScript((value) => {
|
||||||
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||||
|
}, chatDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallGroup(page: Page, commandCount: number): Locator {
|
||||||
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
|
return timeline
|
||||||
|
.getByTestId("tool-call-group")
|
||||||
|
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rememberToolCallGroupPosition(
|
||||||
|
page: Page,
|
||||||
|
commandCount: number,
|
||||||
|
): Promise<ToolCallGroupPositionSnapshot> {
|
||||||
|
const group = toolCallGroup(page, commandCount);
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
const box = await group.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
|
||||||
|
}
|
||||||
|
return { top: box.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
|
||||||
|
export async function expectToolCallGroupPositionPreserved(
|
||||||
|
page: Page,
|
||||||
|
commandCount: number,
|
||||||
|
before: ToolCallGroupPositionSnapshot,
|
||||||
|
): Promise<void> {
|
||||||
|
const group = toolCallGroup(page, commandCount);
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const box = await group.boundingBox();
|
||||||
|
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||||
|
},
|
||||||
|
{ message: `the group of ${commandCount} commands keeps its viewport position` },
|
||||||
|
)
|
||||||
|
.toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
|
||||||
export async function scrollThroughOlderHistoryPages(
|
export async function scrollThroughOlderHistoryPages(
|
||||||
page: Page,
|
page: Page,
|
||||||
pageCount: number,
|
pageCount: number,
|
||||||
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
|
|||||||
export async function holdOlderHistoryPages(
|
export async function holdOlderHistoryPages(
|
||||||
page: Page,
|
page: Page,
|
||||||
agent: MockAgentWorkspace,
|
agent: MockAgentWorkspace,
|
||||||
daemonPort?: number,
|
options?: OlderTimelinePagesOptions,
|
||||||
): Promise<OlderHistoryPages> {
|
): Promise<OlderHistoryPages> {
|
||||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||||
page,
|
page,
|
||||||
agent.agentId,
|
agent.agentId,
|
||||||
daemonPort,
|
options,
|
||||||
);
|
);
|
||||||
|
const loader = page.getByTestId("load-older-history-spinner");
|
||||||
return {
|
return {
|
||||||
async expectRequestedPages(count) {
|
async expectRequestedPages(count) {
|
||||||
await gate.waitForRequestCount(count);
|
await gate.waitForRequestCount(count);
|
||||||
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
|
|||||||
},
|
},
|
||||||
async expectSettledWithRequestedPages(count) {
|
async expectSettledWithRequestedPages(count) {
|
||||||
await waitForTimelineGeometryToSettle(page);
|
await waitForTimelineGeometryToSettle(page);
|
||||||
expect(gate.getRequestCount()).toBe(count);
|
// The loader stays until the prepend settles and the viewport decides whether to
|
||||||
|
// keep loading. Once it hides, no further page loads without new user intent.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => ({
|
||||||
|
requestedPages: gate.getRequestCount(),
|
||||||
|
loading: await loader.isVisible(),
|
||||||
|
}),
|
||||||
|
{ message: `older history settles after ${count} requested page(s)` },
|
||||||
|
)
|
||||||
|
.toEqual({ requestedPages: count, loading: false });
|
||||||
},
|
},
|
||||||
expectNoRepeatedEntries() {
|
expectNoRepeatedEntries() {
|
||||||
expect(gate.getRepeatedEntryCount()).toBe(0);
|
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||||
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
|
|||||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
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> {
|
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
||||||
|
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
type WorkspaceTab,
|
type WorkspaceTab,
|
||||||
} from "../../../src/workspace-tabs/model";
|
} from "../../../src/workspace-tabs/model";
|
||||||
import { getServerId } from "./server-id";
|
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. */
|
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
|
||||||
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
|
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
|
||||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.13.1",
|
"version": "0.14.0-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||||
"@xterm/xterm": "^6.1.0-beta.213",
|
"@xterm/xterm": "6.1.0-beta.292",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"expo": "~57.0.30",
|
"expo": "~57.0.30",
|
||||||
"expo-asset": "~57.0.20",
|
"expo-asset": "~57.0.20",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
|
|||||||
isTurnActive: input.isTurnActive,
|
isTurnActive: input.isTurnActive,
|
||||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||||
liveHead: [],
|
liveHead: [],
|
||||||
timingByAssistantId: timingFor(assistant.id),
|
timingByItemId: timingFor(assistant.id),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import { continuesTurn } from "./turn-membership";
|
||||||
|
|
||||||
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export function findMountedWindowStart(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
|
||||||
startIndex -= 1;
|
startIndex -= 1;
|
||||||
}
|
}
|
||||||
return startIndex;
|
return startIndex;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function layoutFor(input: {
|
|||||||
strategy,
|
strategy,
|
||||||
streamHead: input.head ?? [],
|
streamHead: input.head ?? [],
|
||||||
}),
|
}),
|
||||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
timingByItemId: timingFor(...(input.timingIds ?? [])),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("layoutStream", () => {
|
describe("layoutStream", () => {
|
||||||
it("places one response footer after an adjacent tagged tool-only turn", () => {
|
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
|
||||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||||
const layout = layoutFor({
|
const layout = layoutFor({
|
||||||
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
|
|||||||
timingIds: [priorAssistant.id],
|
timingIds: [priorAssistant.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
|
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
|
||||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||||
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
|
|||||||
isTurnActive: true,
|
isTurnActive: true,
|
||||||
history,
|
history,
|
||||||
liveHead,
|
liveHead,
|
||||||
timingByAssistantId: timingFor(priorAssistant.id),
|
timingByItemId: timingFor(priorAssistant.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||||
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
|
|||||||
isTurnActive: true,
|
isTurnActive: true,
|
||||||
history,
|
history,
|
||||||
liveHead,
|
liveHead,
|
||||||
timingByAssistantId: timingFor(priorAssistant.id),
|
timingByItemId: timingFor(priorAssistant.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(footerOwners(first)).toEqual([]);
|
expect(footerOwners(first)).toEqual([]);
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
|||||||
isTurnActive: boolean;
|
isTurnActive: boolean;
|
||||||
history: StreamItem[];
|
history: StreamItem[];
|
||||||
liveHead: StreamItem[];
|
liveHead: StreamItem[];
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByItemId: Map<string, TurnTiming>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutSegmentInput {
|
interface LayoutSegmentInput {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByItemId: Map<string, TurnTiming>;
|
||||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||||
hasAuxiliaryFooter: boolean;
|
hasAuxiliaryFooter: boolean;
|
||||||
frameOrder: StreamFrameChildOrder;
|
frameOrder: StreamFrameChildOrder;
|
||||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
|||||||
phase: "streaming" | "complete";
|
phase: "streaming" | "complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantFooterSource {
|
interface TurnFooterSource {
|
||||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
item: StreamItem;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
@@ -67,23 +67,26 @@ function createTurnFooterHost(input: {
|
|||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
index: number;
|
index: number;
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByItemId: Map<string, TurnTiming>;
|
||||||
}): TurnFooterHost {
|
}): TurnFooterHost {
|
||||||
return {
|
return {
|
||||||
itemId: input.item.id,
|
itemId: input.item.id,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
timing: input.timingByAssistantId.get(input.item.id),
|
timing: input.timingByItemId.get(input.item.id),
|
||||||
startIndex: input.index,
|
startIndex: input.index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLatestAssistantInResponse(input: {
|
function findTurnFooterSource(input: {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
boundaryAboveItems?: StreamItem[] | null;
|
boundaryAboveItems?: StreamItem[] | null;
|
||||||
boundaryAboveIndex?: number | null;
|
boundaryAboveIndex?: number | null;
|
||||||
}): AssistantFooterSource | null {
|
}): TurnFooterSource | null {
|
||||||
|
const last = input.items[input.startIndex];
|
||||||
|
const toolFallback: TurnFooterSource | null =
|
||||||
|
last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null;
|
||||||
let items = input.items;
|
let items = input.items;
|
||||||
let index = input.startIndex;
|
let index = input.startIndex;
|
||||||
let canCrossBoundary = true;
|
let canCrossBoundary = true;
|
||||||
@@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: {
|
|||||||
) {
|
) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||||
return null;
|
return toolFallback;
|
||||||
}
|
}
|
||||||
if (item.kind === "assistant_message") {
|
if (item.kind === "assistant_message") {
|
||||||
return { item, items, index };
|
return { item, items, index };
|
||||||
@@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: {
|
|||||||
input.boundaryAboveIndex === null ||
|
input.boundaryAboveIndex === null ||
|
||||||
input.boundaryAboveIndex === undefined
|
input.boundaryAboveIndex === undefined
|
||||||
) {
|
) {
|
||||||
return null;
|
return toolFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
items = input.boundaryAboveItems;
|
items = input.boundaryAboveItems;
|
||||||
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = findLatestAssistantInResponse({
|
const source = findTurnFooterSource({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: footerItems,
|
items: footerItems,
|
||||||
startIndex: latestIndex,
|
startIndex: latestIndex,
|
||||||
});
|
});
|
||||||
if (!assistant) {
|
if (!source) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createTurnFooterHost({
|
return createTurnFooterHost({
|
||||||
item: assistant.item,
|
item: source.item,
|
||||||
items: assistant.items,
|
items: source.items,
|
||||||
index: assistant.index,
|
index: source.index,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByItemId: input.timingByItemId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
|
|||||||
index: number;
|
index: number;
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
belowItem: StreamItem | null;
|
belowItem: StreamItem | null;
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByItemId: Map<string, TurnTiming>;
|
||||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||||
boundaryAboveItems: StreamItem[] | null;
|
boundaryAboveItems: StreamItem[] | null;
|
||||||
boundaryAboveIndex: number | null;
|
boundaryAboveIndex: number | null;
|
||||||
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = findLatestAssistantInResponse({
|
const source = findTurnFooterSource({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
startIndex: input.index,
|
startIndex: input.index,
|
||||||
boundaryAboveItems: input.boundaryAboveItems,
|
boundaryAboveItems: input.boundaryAboveItems,
|
||||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||||
});
|
});
|
||||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createTurnFooterHost({
|
return createTurnFooterHost({
|
||||||
item: assistant.item,
|
item: source.item,
|
||||||
items: assistant.items,
|
items: source.items,
|
||||||
index: assistant.index,
|
index: source.index,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByItemId: input.timingByItemId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +309,7 @@ function layoutSegmentItem(
|
|||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
belowItem,
|
belowItem,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByItemId: input.timingByItemId,
|
||||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||||
boundaryAboveItems: input.boundaryAboveItems,
|
boundaryAboveItems: input.boundaryAboveItems,
|
||||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||||
@@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
history = layoutSegment({
|
history = layoutSegment({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.history,
|
items: input.history,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByItemId: input.timingByItemId,
|
||||||
auxiliaryTurnFooter,
|
auxiliaryTurnFooter,
|
||||||
hasAuxiliaryFooter,
|
hasAuxiliaryFooter,
|
||||||
frameOrder,
|
frameOrder,
|
||||||
@@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
const liveHead = layoutSegment({
|
const liveHead = layoutSegment({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.liveHead,
|
items: input.liveHead,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByItemId: input.timingByItemId,
|
||||||
auxiliaryTurnFooter,
|
auxiliaryTurnFooter,
|
||||||
hasAuxiliaryFooter,
|
hasAuxiliaryFooter,
|
||||||
frameOrder,
|
frameOrder,
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
historyStart: 2,
|
historyStart: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
|
||||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
|
||||||
completedAt: tail[3]?.timestamp,
|
completedAt: tail[3]?.timestamp,
|
||||||
durationMs: 1000,
|
durationMs: 1000,
|
||||||
});
|
});
|
||||||
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
||||||
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
|
expect(model.turnTiming.byItemId.has("live-a")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps completed turn timing to assistant ids across committed history and live head", () => {
|
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.runningStartedAt).toBe(null);
|
||||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
expect(model.turnTiming.byItemId.get("live-a")).toEqual({
|
||||||
completedAt: head[0]?.timestamp,
|
completedAt: head[0]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
expect(model.turnTiming.byItemId.get("a1")).toEqual({
|
||||||
completedAt: tail[1]?.timestamp,
|
completedAt: tail[1]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
isMobileBreakpoint: false,
|
isMobileBreakpoint: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
expect(model.turnTiming.byItemId.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["web", "native"] as const)(
|
it.each(["web", "native"] as const)(
|
||||||
@@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(second.history).toBe(first.history);
|
expect(second.history).toBe(first.history);
|
||||||
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
|
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
|
||||||
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
|
expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId);
|
||||||
expect(first.history).toHaveLength(20);
|
expect(first.history).toHaveLength(20);
|
||||||
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
|
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
|
||||||
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
|
expect(first.turnTiming.byItemId.get("a38")).toEqual({
|
||||||
completedAt: tail[77]?.timestamp,
|
completedAt: tail[77]?.timestamp,
|
||||||
durationMs: 1000,
|
durationMs: 1000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildAgentStreamRenderModel } from "./model";
|
import { buildAgentStreamRenderModel } from "./model";
|
||||||
import { createStreamPresentation } from "./presentation";
|
import { createStreamPresentation } from "./presentation";
|
||||||
|
import { createReadingAnchor } from "./reading-anchor";
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
paseo: {},
|
paseo: {},
|
||||||
@@ -588,3 +589,197 @@ describe("timeline presentation", () => {
|
|||||||
expect(projectTimelineItems([source], () => [])).toEqual([]);
|
expect(projectTimelineItems([source], () => [])).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opening a turn reveals individual calls rather than another tool group", () => {
|
||||||
|
const present = createStreamPresentation();
|
||||||
|
const timestamp = new Date();
|
||||||
|
const tail: StreamItem[] = [
|
||||||
|
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||||
|
toolCall("one", "Bash"),
|
||||||
|
toolCall("two", "Bash"),
|
||||||
|
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||||
|
];
|
||||||
|
const result = present({
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
display: "collapseTurns",
|
||||||
|
isTurnActive: false,
|
||||||
|
expandedTurnHostIds: new Set(["prompt"]),
|
||||||
|
});
|
||||||
|
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||||
|
expect(result.groupsByHostId.size).toBe(0);
|
||||||
|
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||||
|
"preserves the reader when a prepend extends a group in %s",
|
||||||
|
(display) => {
|
||||||
|
const present = createStreamPresentation();
|
||||||
|
const anchor = createReadingAnchor();
|
||||||
|
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
|
||||||
|
const input = {
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
display,
|
||||||
|
head: [],
|
||||||
|
isTurnActive: false,
|
||||||
|
};
|
||||||
|
const partial = present({ ...input, tail: calls.slice(1) });
|
||||||
|
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
|
||||||
|
const completed = present({ ...input, tail: calls });
|
||||||
|
const groupId = completed.tail[0]!.id;
|
||||||
|
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
|
||||||
|
expect(
|
||||||
|
anchor.reconcile(
|
||||||
|
0,
|
||||||
|
[{ id: groupId, top: 448, height: 200 }],
|
||||||
|
false,
|
||||||
|
completed.readingRowReplacements,
|
||||||
|
),
|
||||||
|
).toBe(400);
|
||||||
|
expect(anchor.getRowId()).toBe(groupId);
|
||||||
|
const timestamp = new Date();
|
||||||
|
const folded = present({
|
||||||
|
...input,
|
||||||
|
display: "collapseTurns",
|
||||||
|
tail: [
|
||||||
|
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||||
|
...calls,
|
||||||
|
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Both the original partial group and its replacement resolve directly to the turn.
|
||||||
|
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||||
|
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||||
|
const opened = present({ ...input, tail: calls, display: "full" });
|
||||||
|
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
|
||||||
|
const call = toolCall("received", "agent_message");
|
||||||
|
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||||
|
call.turnId = "turn";
|
||||||
|
call.payload.data.agentMessage = {
|
||||||
|
event: "message",
|
||||||
|
sender: { id: "sender", title: "Reviewer" },
|
||||||
|
text: "Ready",
|
||||||
|
};
|
||||||
|
const result = createStreamPresentation()({
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
tail: [call],
|
||||||
|
head: [],
|
||||||
|
});
|
||||||
|
expect(result.tail[0]).toMatchObject({
|
||||||
|
kind: "user_message",
|
||||||
|
id: call.id,
|
||||||
|
turnId: "turn",
|
||||||
|
text: "Ready",
|
||||||
|
sender: { id: "sender", title: "Reviewer" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reopen the previous turn while a new run waits for its first row", () => {
|
||||||
|
const timestamp = new Date();
|
||||||
|
const tail: StreamItem[] = [
|
||||||
|
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||||
|
toolCall("one", "Bash"),
|
||||||
|
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||||
|
];
|
||||||
|
for (const item of tail) item.turnId = "completed";
|
||||||
|
const result = createStreamPresentation()({
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
display: "collapseTurns",
|
||||||
|
isTurnActive: true,
|
||||||
|
activeTurnId: "new",
|
||||||
|
});
|
||||||
|
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||||
|
// the completed one; it must stay folded until the new turn's rows arrive.
|
||||||
|
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
|
||||||
|
const timestamp = new Date();
|
||||||
|
const tail: StreamItem[] = [
|
||||||
|
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||||
|
toolCall("one", "Bash"),
|
||||||
|
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||||
|
];
|
||||||
|
for (const item of tail) item.turnId = "completed";
|
||||||
|
const result = createStreamPresentation()({
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
display: "collapseTurns",
|
||||||
|
isTurnActive: true,
|
||||||
|
activeTurnId: null,
|
||||||
|
});
|
||||||
|
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||||
|
const present = createStreamPresentation();
|
||||||
|
const timestamp = new Date();
|
||||||
|
const thought: StreamItem = {
|
||||||
|
kind: "thought",
|
||||||
|
id: "thought",
|
||||||
|
text: "Consider this",
|
||||||
|
status: "ready",
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
|
||||||
|
for (const item of tail) item.turnId = "run";
|
||||||
|
const input = {
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
display: "collapseToolCalls" as const,
|
||||||
|
isTurnActive: false,
|
||||||
|
};
|
||||||
|
const grouped = present({ ...input, autoExpandReasoning: false });
|
||||||
|
expect(grouped.tail).toHaveLength(1);
|
||||||
|
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
|
||||||
|
const separate = present({ ...input, autoExpandReasoning: true });
|
||||||
|
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
|
||||||
|
expect(separate.groupsByHostId.has("thought")).toBe(false);
|
||||||
|
const next = { ...thought, id: "next", turnId: "next-run" };
|
||||||
|
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
|
||||||
|
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"reveals raw reasoning when opening a turn (Expand thinking %s)",
|
||||||
|
(autoExpandReasoning) => {
|
||||||
|
const timestamp = new Date();
|
||||||
|
const tail: StreamItem[] = [
|
||||||
|
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||||
|
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
|
||||||
|
toolCall("one", "Bash"),
|
||||||
|
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||||
|
];
|
||||||
|
const input = {
|
||||||
|
...presentationOptions,
|
||||||
|
transform: undefined,
|
||||||
|
tail,
|
||||||
|
head: [],
|
||||||
|
display: "collapseTurns" as const,
|
||||||
|
isTurnActive: false,
|
||||||
|
autoExpandReasoning,
|
||||||
|
};
|
||||||
|
const present = createStreamPresentation();
|
||||||
|
const closed = present(input);
|
||||||
|
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
|
||||||
|
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
|
||||||
|
expect(opened.tail).toEqual(tail);
|
||||||
|
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
|
||||||
|
expect(opened.groupsByHostId.size).toBe(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isTurnBoundary } from "./turn-membership";
|
||||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||||
@@ -10,19 +11,38 @@ import {
|
|||||||
} from "@/tool-calls/detail-level/projection";
|
} from "@/tool-calls/detail-level/projection";
|
||||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||||
|
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||||
|
|
||||||
interface PresentationInput {
|
interface PresentationInput {
|
||||||
tail: StreamItem[];
|
tail: StreamItem[];
|
||||||
head: StreamItem[];
|
head: StreamItem[];
|
||||||
transform: TimelineItemTransform | undefined;
|
transform: TimelineItemTransform | undefined;
|
||||||
display: ChatDisplay;
|
display: ChatDisplay;
|
||||||
|
autoExpandReasoning?: boolean;
|
||||||
/** User messages whose collapsed turn the reader opened. */
|
/** User messages whose collapsed turn the reader opened. */
|
||||||
expandedTurnHostIds: ReadonlySet<string>;
|
expandedTurnHostIds: ReadonlySet<string>;
|
||||||
isTurnActive: boolean;
|
isTurnActive: boolean;
|
||||||
|
activeTurnId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||||
turns: Omit<TurnCollapse, "tail">;
|
turns: Omit<TurnCollapse, "tail">;
|
||||||
|
readingRowReplacements: ReadingRowReplacements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupedRowHosts(
|
||||||
|
rows: readonly StreamItem[],
|
||||||
|
groups: ToolCallDetailProjection["groupsByHostId"],
|
||||||
|
): ReadonlyMap<string, string> {
|
||||||
|
const hosts = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const group = groups.get(row.id);
|
||||||
|
if (!group) continue;
|
||||||
|
for (const call of group.run.calls) {
|
||||||
|
if (call.id !== row.id) hosts.set(call.id, row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||||
@@ -55,27 +75,54 @@ export function createStreamPresentation() {
|
|||||||
let historyTransform: TimelineItemTransform | undefined;
|
let historyTransform: TimelineItemTransform | undefined;
|
||||||
let historyRows: StreamItem[] = [];
|
let historyRows: StreamItem[] = [];
|
||||||
let preparedTail: StreamItem[] | undefined;
|
let preparedTail: StreamItem[] | undefined;
|
||||||
|
let preparedAutoExpandReasoning: boolean | undefined;
|
||||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||||
|
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
|
||||||
let collapsedSource: StreamItem[] | undefined;
|
let collapsedSource: StreamItem[] | undefined;
|
||||||
let collapsedIsLatestTurnOpen = false;
|
let collapsedIsLatestTurnOpen = false;
|
||||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||||
let collapsed: TurnCollapse | undefined;
|
let collapsed: TurnCollapse | undefined;
|
||||||
|
|
||||||
function presentRows(item: StreamItem): StreamItem[] {
|
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];
|
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapse(
|
function collapse(
|
||||||
projection: ToolCallDetailProjection,
|
projection: ToolCallDetailProjection,
|
||||||
input: PresentationInput,
|
input: PresentationInput,
|
||||||
): StreamPresentation {
|
): Omit<StreamPresentation, "readingRowReplacements"> {
|
||||||
if (input.display !== "collapseTurns") {
|
if (input.display !== "collapseTurns") {
|
||||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||||
}
|
}
|
||||||
// Live text updates reach the head only, so the folded history is reused until the
|
// 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.
|
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
const last = projection.tail.at(-1) ?? null;
|
||||||
|
const first = projection.head[0] ?? null;
|
||||||
|
const activeTurnIsAnother = Boolean(
|
||||||
|
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
|
||||||
|
);
|
||||||
|
// Without head rows, the latest retained turn is live only while the active run is that
|
||||||
|
// turn. A run that has not opened yet carries no id and belongs to the next turn.
|
||||||
|
const isLatestTurnOpen = first
|
||||||
|
? !isTurnBoundary(last, first) && !activeTurnIsAnother
|
||||||
|
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
|
||||||
if (
|
if (
|
||||||
!collapsed ||
|
!collapsed ||
|
||||||
collapsedSource !== projection.tail ||
|
collapsedSource !== projection.tail ||
|
||||||
@@ -93,7 +140,20 @@ export function createStreamPresentation() {
|
|||||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||||
}
|
}
|
||||||
const { tail, ...turns } = collapsed;
|
const { tail, ...turns } = collapsed;
|
||||||
return { ...projection, tail, turns };
|
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
|
||||||
|
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
|
||||||
|
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
|
||||||
|
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
|
||||||
|
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
|
||||||
|
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...projection,
|
||||||
|
tail,
|
||||||
|
turns,
|
||||||
|
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
|
||||||
|
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (input: PresentationInput): StreamPresentation => {
|
return (input: PresentationInput): StreamPresentation => {
|
||||||
@@ -111,20 +171,48 @@ export function createStreamPresentation() {
|
|||||||
const blocks = presentRows(item);
|
const blocks = presentRows(item);
|
||||||
head.push(...blocks);
|
head.push(...blocks);
|
||||||
}
|
}
|
||||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
if (
|
||||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
preparedTail !== historyRows ||
|
||||||
|
preparedLevel !== level ||
|
||||||
|
preparedAutoExpandReasoning !== input.autoExpandReasoning
|
||||||
|
) {
|
||||||
|
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
|
||||||
|
historyGroupHosts = preparedHistory
|
||||||
|
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
|
||||||
|
: new Map();
|
||||||
preparedTail = historyRows;
|
preparedTail = historyRows;
|
||||||
preparedLevel = level;
|
preparedLevel = level;
|
||||||
|
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||||
}
|
}
|
||||||
return collapse(
|
const projection = projectToolCallDetailLevel({
|
||||||
projectToolCallDetailLevel({
|
|
||||||
level,
|
level,
|
||||||
tail: historyRows,
|
tail: historyRows,
|
||||||
head,
|
head,
|
||||||
preparedHistory,
|
preparedHistory,
|
||||||
isTurnActive: input.isTurnActive,
|
isTurnActive: input.isTurnActive,
|
||||||
}),
|
});
|
||||||
input,
|
const result = collapse(projection, input);
|
||||||
|
// Only the trailing retained group can absorb head rows. Reuse the retained
|
||||||
|
// index across streaming updates rather than walking the full history again.
|
||||||
|
const last = projection.tail.at(-1);
|
||||||
|
const liveGroupHosts = groupedRowHosts(
|
||||||
|
last ? [last, ...projection.head] : projection.head,
|
||||||
|
projection.groupsByHostId,
|
||||||
);
|
);
|
||||||
|
const retainedGroupHosts = historyGroupHosts;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
readingRowReplacements: {
|
||||||
|
get(id) {
|
||||||
|
if (result.turns.nestedItemIds.has(id)) return undefined;
|
||||||
|
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
|
||||||
|
return (
|
||||||
|
result.turns.hostIdByHiddenRowId.get(id) ??
|
||||||
|
result.turns.hostIdByHiddenRowId.get(groupHost) ??
|
||||||
|
(groupHost === id ? undefined : groupHost)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { createReadingAnchor } from "./reading-anchor";
|
import { createReadingAnchor } from "./reading-anchor";
|
||||||
|
|
||||||
describe("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 = [
|
const rows = [
|
||||||
{ id: "above", top: 0, height: 500 },
|
{ id: "above", top: 0, height: 500 },
|
||||||
{ id: "reading", top: 500, height: 500 },
|
{ id: "reading", top: 500, height: 500 },
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ interface RowGeometry {
|
|||||||
|
|
||||||
// A row must clear the reading line before the next row takes ownership.
|
// A row must clear the reading line before the next row takes ownership.
|
||||||
const READING_POSITION_OFFSET_PX = 8;
|
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;
|
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||||
// scroll events reuse it, without another DOM measurement pass.
|
// scroll events reuse it, without another DOM measurement pass.
|
||||||
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
|
|||||||
let geometry: readonly RowGeometry[] = [];
|
let geometry: readonly RowGeometry[] = [];
|
||||||
const readingRow = (scrollTop: number) =>
|
const readingRow = (scrollTop: number) =>
|
||||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
const getRowId = (replacements = NO_REPLACEMENTS) =>
|
||||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||||
|
const project = (
|
||||||
|
scrollTop: number,
|
||||||
|
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||||
|
replacements = NO_REPLACEMENTS,
|
||||||
|
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||||
return {
|
return {
|
||||||
getRowId: () => anchor?.id ?? null,
|
getRowId,
|
||||||
getReadingRowId: (scrollTop: number) =>
|
getReadingRowId: (scrollTop: number) =>
|
||||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||||
project,
|
project,
|
||||||
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
|
|||||||
const next = readingRow(scrollTop);
|
const next = readingRow(scrollTop);
|
||||||
anchor = next ? { id: next.id, top: next.top } : null;
|
anchor = next ? { id: next.id, top: next.top } : null;
|
||||||
},
|
},
|
||||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
reconcile(
|
||||||
|
scrollTop: number,
|
||||||
|
rows: readonly RowGeometry[],
|
||||||
|
userScrolled = false,
|
||||||
|
replacements = NO_REPLACEMENTS,
|
||||||
|
): number {
|
||||||
geometry = rows;
|
geometry = rows;
|
||||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
|
||||||
// The first virtualized commit can precede its mounted range. Keep the
|
// The first virtualized commit can precede its mounted range. Keep the
|
||||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
|
||||||
// A prepend can expose the bottom of an estimated row above the reader.
|
// 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.
|
// Do not transfer ownership to it until the user moves the reading position.
|
||||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
).toBe("assistant-1\n\nassistant-2");
|
).toBe("assistant-1\n\nassistant-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collects copy content across adjacent turns without a visible prompt", () => {
|
it("keeps copied content within the canonical turn without a visible prompt", () => {
|
||||||
const chronological: StreamItem[] = [
|
const chronological: StreamItem[] = [
|
||||||
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
||||||
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
||||||
@@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
items: chronological,
|
items: chronological,
|
||||||
startIndex: 2,
|
startIndex: 2,
|
||||||
}),
|
}),
|
||||||
).toBe("first turn\n\nsecond turn");
|
).toBe("second turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
||||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||||
import { createWebStreamStrategy } from "./strategy-web";
|
import { createWebStreamStrategy } from "./strategy-web";
|
||||||
|
|
||||||
@@ -131,6 +131,79 @@ describe("createWebStreamStrategy", () => {
|
|||||||
vi.restoreAllMocks();
|
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", () => {
|
it("mounts virtualized history without recursive row measurement updates", () => {
|
||||||
const rowRenderCount = vi.fn();
|
const rowRenderCount = vi.fn();
|
||||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const rangeExtractor = useCallback(
|
const rangeExtractor = useCallback(
|
||||||
(range: VirtualRange): number[] => {
|
(range: VirtualRange): number[] => {
|
||||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||||
(row) => row.id === readingAnchor.getRowId(),
|
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
|
||||||
);
|
);
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
// TanStack has computed the new measurements before calling the extractor.
|
// TanStack has computed the new measurements before calling the extractor.
|
||||||
@@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||||
const correctedOffset =
|
const correctedOffset =
|
||||||
container && measurement
|
container && measurement
|
||||||
? readingAnchor.project(container.scrollTop, {
|
? readingAnchor.project(
|
||||||
|
container.scrollTop,
|
||||||
|
{
|
||||||
id: String(measurement.key),
|
id: String(measurement.key),
|
||||||
top: measurement.start,
|
top: measurement.start,
|
||||||
})
|
},
|
||||||
|
props.readingRowReplacements,
|
||||||
|
)
|
||||||
: container?.scrollTop;
|
: container?.scrollTop;
|
||||||
let visibleRange = range;
|
let visibleRange = range;
|
||||||
if (
|
if (
|
||||||
@@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
]),
|
]),
|
||||||
].sort((left, right) => left - right);
|
].sort((left, right) => left - right);
|
||||||
},
|
},
|
||||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
|
||||||
);
|
);
|
||||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||||
@@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
);
|
);
|
||||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||||
if (!shouldAnchor) readingAnchor.reset();
|
if (!shouldAnchor) readingAnchor.reset();
|
||||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
const correctedTop = readingAnchor.reconcile(
|
||||||
|
container.scrollTop,
|
||||||
|
rows,
|
||||||
|
false,
|
||||||
|
props.readingRowReplacements,
|
||||||
|
);
|
||||||
if (!shouldAnchor) {
|
if (!shouldAnchor) {
|
||||||
readingAnchor.reset();
|
readingAnchor.reset();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||||
import type { StyleProp, ViewStyle } from "react-native";
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||||
import { continuesResponse } from "./turn-membership";
|
import { continuesResponse } from "./turn-membership";
|
||||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||||
import type {
|
import type {
|
||||||
@@ -78,6 +79,8 @@ export interface StreamRenderInput {
|
|||||||
segments: StreamRenderSegments;
|
segments: StreamRenderSegments;
|
||||||
historyRowRevision?: StreamHistoryRowRevision;
|
historyRowRevision?: StreamHistoryRowRevision;
|
||||||
liveHeadRowRevision?: unknown;
|
liveHeadRowRevision?: unknown;
|
||||||
|
/** Presentation rows replaced by another row after history is folded. */
|
||||||
|
readingRowReplacements?: ReadingRowReplacements;
|
||||||
boundary: StreamHistoryBoundary;
|
boundary: StreamHistoryBoundary;
|
||||||
renderers: StreamSegmentRenderers;
|
renderers: StreamSegmentRenderers;
|
||||||
listEmptyComponent: ReactNode;
|
listEmptyComponent: ReactNode;
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
|
|||||||
isTurnActive: true,
|
isTurnActive: true,
|
||||||
history: model.history,
|
history: model.history,
|
||||||
liveHead: model.segments.liveHead,
|
liveHead: model.segments.liveHead,
|
||||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
timingByItemId: model.turnTiming.byItemId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,49 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
|||||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||||
|
|
||||||
describe("collapseTurns", () => {
|
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", () => {
|
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||||
const result = collapse([
|
const result = collapse([
|
||||||
user("u1"),
|
user("u1"),
|
||||||
@@ -63,7 +106,6 @@ describe("collapseTurns", () => {
|
|||||||
hostId: "u1",
|
hostId: "u1",
|
||||||
expanded: false,
|
expanded: false,
|
||||||
summary: {
|
summary: {
|
||||||
messageCount: 1,
|
|
||||||
tools: expect.objectContaining({ commandCount: 2 }),
|
tools: expect.objectContaining({ commandCount: 2 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -121,6 +163,34 @@ describe("collapseTurns", () => {
|
|||||||
expect(result.turnsByHostId.size).toBe(0);
|
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", () => {
|
it("counts every call a tool-call group row stands for", () => {
|
||||||
const group = [shell("c1"), shell("c2"), shell("c3")];
|
const group = [shell("c1"), shell("c2"), shell("c3")];
|
||||||
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
|
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
|
||||||
|
import {
|
||||||
|
getStreamItemMessageId,
|
||||||
|
type StreamItem,
|
||||||
|
type ThoughtItem,
|
||||||
|
type ToolCallItem,
|
||||||
|
} from "@/types/stream";
|
||||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||||
|
|
||||||
export interface TurnSummary {
|
export interface TurnSummary {
|
||||||
messageCount: number;
|
|
||||||
tools: OverviewSummary;
|
tools: OverviewSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollapsedTurn {
|
export interface CollapsedTurn {
|
||||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
/** The input or first loaded row that carries the turn's summary line. */
|
||||||
hostId: string;
|
hostId: string;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
summary: TurnSummary;
|
summary: TurnSummary;
|
||||||
@@ -20,6 +25,8 @@ export interface TurnCollapse {
|
|||||||
nestedItemIds: ReadonlySet<string>;
|
nestedItemIds: ReadonlySet<string>;
|
||||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
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 {
|
export interface CollapseTurnsInput {
|
||||||
@@ -28,7 +35,7 @@ export interface CollapseTurnsInput {
|
|||||||
isLatestTurnOpen: boolean;
|
isLatestTurnOpen: boolean;
|
||||||
expandedHostIds: ReadonlySet<string>;
|
expandedHostIds: ReadonlySet<string>;
|
||||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||||
@@ -39,17 +46,21 @@ function summarizeTurn(
|
|||||||
items: readonly StreamItem[],
|
items: readonly StreamItem[],
|
||||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||||
): TurnSummary {
|
): TurnSummary {
|
||||||
const calls: ToolCallItem[] = [];
|
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||||
let messageCount = 0;
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.kind === "assistant_message") messageCount += 1;
|
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||||
}
|
}
|
||||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
return { tools: summarizeToolCalls(calls) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasWork(summary: TurnSummary): boolean {
|
function hasWork(summary: TurnSummary): boolean {
|
||||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
|
||||||
|
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||||
|
return isTurnTrigger(item) || item.kind === "compaction";
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||||
@@ -63,7 +74,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number):
|
|||||||
* Folds each answered turn into its user message: everything the agent did before its last
|
* 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
|
* 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
|
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||||
* before its reply. Thoughts fold with the turn but are not counted.
|
* before its reply. Reasoning is revealed only when the turn opens.
|
||||||
*/
|
*/
|
||||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||||
const { tail } = input;
|
const { tail } = input;
|
||||||
@@ -71,34 +82,55 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
|||||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||||
const nestedItemIds = new Set<string>();
|
const nestedItemIds = new Set<string>();
|
||||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||||
|
const hostIdByHiddenRowId = new Map<string, string>();
|
||||||
|
|
||||||
let runStart = 0;
|
let runStart = 0;
|
||||||
const flushRun = (runEnd: number) => {
|
const flushRun = (runEnd: number) => {
|
||||||
const host = tail[runStart - 1];
|
const host = tail[runStart - 1];
|
||||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||||
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
|
if (
|
||||||
|
!host ||
|
||||||
|
(host.kind !== "user_message" && host.kind !== "tool_call") ||
|
||||||
|
!isClosed ||
|
||||||
|
replyIndex <= runStart
|
||||||
|
) {
|
||||||
output.push(...tail.slice(runStart, runEnd));
|
output.push(...tail.slice(runStart, runEnd));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const folded = tail.slice(runStart, replyIndex);
|
const folded = tail.slice(runStart, replyIndex);
|
||||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||||
if (!hasWork(summary)) {
|
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
|
||||||
output.push(...tail.slice(runStart, runEnd));
|
output.push(...tail.slice(runStart, runEnd));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const expanded = input.expandedHostIds.has(host.id);
|
const expanded = input.expandedHostIds.has(host.id);
|
||||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||||
for (const item of folded) {
|
const revealed = expanded
|
||||||
|
? folded.flatMap<StreamItem>((item) =>
|
||||||
|
item.kind === "tool_call" || item.kind === "thought"
|
||||||
|
? [...(input.getGroupedCalls(item.id) ?? [item])]
|
||||||
|
: [item],
|
||||||
|
)
|
||||||
|
: folded;
|
||||||
|
for (const item of revealed) {
|
||||||
|
if (staysVisibleWhenCollapsed(item)) continue;
|
||||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||||
if (expanded) nestedItemIds.add(item.id);
|
if (expanded) nestedItemIds.add(item.id);
|
||||||
|
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||||
}
|
}
|
||||||
if (expanded) output.push(...folded);
|
output.push(
|
||||||
|
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||||
|
);
|
||||||
output.push(...tail.slice(replyIndex, runEnd));
|
output.push(...tail.slice(replyIndex, runEnd));
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let index = 0; index < tail.length; index += 1) {
|
for (let index = 0; index < tail.length; index += 1) {
|
||||||
if (tail[index]?.kind !== "user_message") continue;
|
if (
|
||||||
|
!(index === 0 && isTurnTrigger(tail[index]!)) &&
|
||||||
|
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
flushRun(index);
|
flushRun(index);
|
||||||
output.push(tail[index]!);
|
output.push(tail[index]!);
|
||||||
runStart = index + 1;
|
runStart = index + 1;
|
||||||
@@ -111,12 +143,17 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
|||||||
turnsByHostId: EMPTY_TURNS,
|
turnsByHostId: EMPTY_TURNS,
|
||||||
nestedItemIds: EMPTY_IDS,
|
nestedItemIds: EMPTY_IDS,
|
||||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||||
|
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
tail: output.length === tail.length ? tail : output,
|
tail:
|
||||||
|
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||||
|
? tail
|
||||||
|
: output,
|
||||||
turnsByHostId,
|
turnsByHostId,
|
||||||
nestedItemIds,
|
nestedItemIds,
|
||||||
hostIdByFoldedMessageId,
|
hostIdByFoldedMessageId,
|
||||||
|
hostIdByHiddenRowId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useMemo } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Summary } from "lucide-react-native";
|
import { Summary } from "lucide-react-native";
|
||||||
import { ExpandableBadge } from "@/components/message";
|
import { ExpandableBadge } from "@/components/message";
|
||||||
@@ -17,19 +17,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
|
|||||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { messageCount, tools } = turn.summary;
|
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||||
const messageParts = useMemo(
|
|
||||||
() =>
|
|
||||||
messageCount > 0
|
|
||||||
? [
|
|
||||||
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
|
|
||||||
count: messageCount,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
[messageCount, t],
|
|
||||||
);
|
|
||||||
const label = useOverviewSummaryLabel(tools, messageParts);
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||||
[onExpandedChange, turn.expanded, turn.hostId],
|
[onExpandedChange, turn.expanded, turn.hostId],
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
|
|||||||
return (
|
return (
|
||||||
<View style={stylesheet.turnFooterSlot}>
|
<View style={stylesheet.turnFooterSlot}>
|
||||||
<AssistantTurnFooter
|
<AssistantTurnFooter
|
||||||
getContent={getContent}
|
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
|
||||||
completedAt={timing?.completedAt}
|
completedAt={timing?.completedAt}
|
||||||
durationMs={timing?.durationMs}
|
durationMs={timing?.durationMs}
|
||||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
|||||||
isTurnActive,
|
isTurnActive,
|
||||||
history: model.segments.historyMounted,
|
history: model.segments.historyMounted,
|
||||||
liveHead: model.segments.liveHead,
|
liveHead: model.segments.liveHead,
|
||||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
timingByItemId: model.turnTiming.byItemId,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,24 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("canonical turn membership", () => {
|
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", () => {
|
it("stops tagged traversal at every mismatching turn ID", () => {
|
||||||
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
|
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -76,7 +94,7 @@ describe("canonical turn membership", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders adjacent turns without visible prompts as one response", () => {
|
it("renders a separate footer for each canonical turn without visible prompts", () => {
|
||||||
const items = [
|
const items = [
|
||||||
user("prompt", 1, "turn-1"),
|
user("prompt", 1, "turn-1"),
|
||||||
assistant("first reply", 2, "turn-1"),
|
assistant("first reply", 2, "turn-1"),
|
||||||
@@ -88,7 +106,11 @@ describe("canonical turn membership", () => {
|
|||||||
|
|
||||||
const completed = layoutFor(items, false);
|
const completed = layoutFor(items, false);
|
||||||
|
|
||||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
expect(completedFooterIds(completed.layout)).toEqual([
|
||||||
|
"first reply",
|
||||||
|
"heartbeat-1-reply",
|
||||||
|
"heartbeat-2-reply",
|
||||||
|
]);
|
||||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||||
completedAt: at(6),
|
completedAt: at(6),
|
||||||
durationMs: null,
|
durationMs: null,
|
||||||
@@ -123,7 +145,7 @@ describe("canonical turn membership", () => {
|
|||||||
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||||
const completed = layoutFor(completedItems, false);
|
const completed = layoutFor(completedItems, false);
|
||||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
|
||||||
completedAt: at(9),
|
completedAt: at(9),
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
|
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical turn IDs take precedence. Timelines without them retain the legacy
|
* Canonical turn IDs take precedence. Timelines without them retain the legacy
|
||||||
* user-message boundary rule so old daemons and persisted rows need no rewrite.
|
* trigger boundary rule; missing IDs cannot reconstruct historical batching.
|
||||||
*/
|
*/
|
||||||
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||||
if (!previous || !next) return false;
|
if (!previous || !next) return false;
|
||||||
if (previous.turnId !== undefined && next.turnId !== undefined) {
|
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
|
||||||
return previous.turnId === next.turnId;
|
|
||||||
}
|
|
||||||
return next.kind !== "user_message";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A visible response can span multiple canonical turns when their prompts are
|
* Every canonical turn owns its response, including system-triggered runs.
|
||||||
* system-injected and therefore absent from the Paseo timeline.
|
|
||||||
*/
|
*/
|
||||||
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
|
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||||
if (!previous || !next) return false;
|
if (!previous || !next) return false;
|
||||||
return continuesTurn(previous, next) || next.kind !== "user_message";
|
return continuesTurn(previous, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||||
@@ -37,3 +34,12 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
|
|||||||
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
|
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
|
||||||
return turnId !== null && item.turnId === turnId;
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||||
|
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||||
import {
|
import {
|
||||||
createWorkspaceFileTabTarget,
|
createWorkspaceFileTabTarget,
|
||||||
type WorkspaceFileLocation,
|
type WorkspaceFileLocation,
|
||||||
@@ -459,6 +460,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
view: "files",
|
view: "files",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||||
const inlinePathOpener = useMemo(
|
const inlinePathOpener = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createInlinePathOpener({
|
createInlinePathOpener({
|
||||||
@@ -466,8 +468,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
client,
|
client,
|
||||||
openFile: openInlineFile,
|
openFile: openInlineFile,
|
||||||
openDirectory: openInlineDirectory,
|
openDirectory: openInlineDirectory,
|
||||||
|
openOutsideDirectory: openInlineOutsideDirectory,
|
||||||
}),
|
}),
|
||||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) inlinePathOpener.cancel();
|
if (!isActive) inlinePathOpener.cancel();
|
||||||
@@ -524,8 +527,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||||
transform: transformTimelineItem,
|
transform: transformTimelineItem,
|
||||||
display: chatDisplay,
|
display: chatDisplay,
|
||||||
|
autoExpandReasoning,
|
||||||
expandedTurnHostIds,
|
expandedTurnHostIds,
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
|
activeTurnId: effectiveTurnPresentation.turnId,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
presentStream,
|
presentStream,
|
||||||
@@ -533,8 +538,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
effectiveStreamHead,
|
effectiveStreamHead,
|
||||||
transformTimelineItem,
|
transformTimelineItem,
|
||||||
chatDisplay,
|
chatDisplay,
|
||||||
|
autoExpandReasoning,
|
||||||
expandedTurnHostIds,
|
expandedTurnHostIds,
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
|
effectiveTurnPresentation.turnId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
@@ -576,12 +583,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isTurnActive,
|
isTurnActive,
|
||||||
history: baseRenderModel.history,
|
history: baseRenderModel.history,
|
||||||
liveHead: baseRenderModel.segments.liveHead,
|
liveHead: baseRenderModel.segments.liveHead,
|
||||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
timingByItemId: baseRenderModel.turnTiming.byItemId,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
baseRenderModel.history,
|
baseRenderModel.history,
|
||||||
baseRenderModel.segments.liveHead,
|
baseRenderModel.segments.liveHead,
|
||||||
baseRenderModel.turnTiming.byAssistantId,
|
baseRenderModel.turnTiming.byItemId,
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
streamRenderStrategy,
|
streamRenderStrategy,
|
||||||
],
|
],
|
||||||
@@ -722,6 +729,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
messageId={item.messageId}
|
messageId={item.messageId}
|
||||||
message={item.text}
|
message={item.text}
|
||||||
|
sender={item.sender}
|
||||||
images={item.images}
|
images={item.images}
|
||||||
attachments={item.attachments}
|
attachments={item.attachments}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
@@ -868,10 +876,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
presentation.groupsByHostId.get(hostId),
|
presentation.groupsByHostId.get(hostId),
|
||||||
);
|
);
|
||||||
const renderToolCallItem = useCallback(
|
const renderToolCallItem = useCallback(
|
||||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
(
|
||||||
|
layoutItem: StreamLayoutItem,
|
||||||
|
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
|
||||||
|
) => {
|
||||||
const group = getToolCallGroup(item.id);
|
const group = getToolCallGroup(item.id);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
return item.kind === "thought"
|
||||||
|
? renderThoughtItem(layoutItem, item)
|
||||||
|
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||||
}
|
}
|
||||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
||||||
return (
|
return (
|
||||||
@@ -884,10 +897,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
{expanded
|
{expanded
|
||||||
? group.run.calls.map((call, index) => (
|
? group.run.calls.map((call, index) => (
|
||||||
<React.Fragment key={call.id}>
|
<React.Fragment key={call.id}>
|
||||||
{renderSingleToolCallItem(
|
{call.kind === "thought" ? (
|
||||||
|
// Reasoning is grouped only while Expand thinking is off, so an opened
|
||||||
|
// group shows each thought as a closed row like any other call.
|
||||||
|
<ThoughtSlot
|
||||||
|
itemId={call.id}
|
||||||
|
text={call.text}
|
||||||
|
status={call.status}
|
||||||
|
isLastInSequence={index === group.run.calls.length - 1}
|
||||||
|
defaultExpanded={false}
|
||||||
|
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderSingleToolCallItem(
|
||||||
call,
|
call,
|
||||||
index === group.run.calls.length - 1,
|
index === group.run.calls.length - 1,
|
||||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
@@ -897,6 +923,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
expandedToolCallGroupIds,
|
expandedToolCallGroupIds,
|
||||||
|
renderThoughtItem,
|
||||||
|
setInlineDetailsExpanded,
|
||||||
getToolCallGroup,
|
getToolCallGroup,
|
||||||
renderSingleToolCallItem,
|
renderSingleToolCallItem,
|
||||||
setToolCallGroupExpanded,
|
setToolCallGroupExpanded,
|
||||||
@@ -914,10 +942,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return renderAssistantMessageItem(layoutItem, item);
|
return renderAssistantMessageItem(layoutItem, item);
|
||||||
|
|
||||||
case "thought":
|
case "thought":
|
||||||
return renderThoughtItem(layoutItem, item);
|
return renderToolCallItem(layoutItem, item);
|
||||||
|
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return renderToolCallItem(layoutItem, item);
|
return (
|
||||||
|
<>
|
||||||
|
{renderToolCallItem(layoutItem, item)}
|
||||||
|
{getCollapsedTurn(item.id) ? (
|
||||||
|
<TurnSummaryLine
|
||||||
|
turn={getCollapsedTurn(item.id)!}
|
||||||
|
onExpandedChange={setTurnExpanded}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
case "notification":
|
case "notification":
|
||||||
return <Notification level={item.level} message={item.message} />;
|
return <Notification level={item.level} message={item.message} />;
|
||||||
@@ -947,8 +985,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
agentId,
|
agentId,
|
||||||
renderUserMessageItem,
|
renderUserMessageItem,
|
||||||
renderAssistantMessageItem,
|
renderAssistantMessageItem,
|
||||||
renderThoughtItem,
|
|
||||||
renderToolCallItem,
|
renderToolCallItem,
|
||||||
|
getCollapsedTurn,
|
||||||
|
setTurnExpanded,
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1169,6 +1208,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||||
{streamRenderStrategy.render({
|
{streamRenderStrategy.render({
|
||||||
|
readingRowReplacements: presentation.readingRowReplacements,
|
||||||
agentId,
|
agentId,
|
||||||
segments: renderModel.segments,
|
segments: renderModel.segments,
|
||||||
historyRowRevision,
|
historyRowRevision,
|
||||||
@@ -1861,12 +1901,11 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper
|
|||||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||||
switch (turnIndent) {
|
switch (turnIndent) {
|
||||||
case "none":
|
case "none":
|
||||||
return <View style={wrapperStyle}>{children}</View>;
|
|
||||||
case "openedHost":
|
case "openedHost":
|
||||||
return (
|
return (
|
||||||
<View style={wrapperStyle}>
|
<View style={wrapperStyle}>
|
||||||
{children}
|
{children}
|
||||||
<IndentRail style={gapStyle} />
|
{turnIndent === "openedHost" ? <IndentRail style={gapStyle} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
case "nested":
|
case "nested":
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("findMountedWindowStart", () => {
|
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", () => {
|
it("keeps all items mounted when the chat is below the threshold", () => {
|
||||||
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
|
|||||||
openDirectory: (directory) => {
|
openDirectory: (directory) => {
|
||||||
opened.push(directory);
|
opened.push(directory);
|
||||||
},
|
},
|
||||||
|
openOutsideDirectory: (directory) => {
|
||||||
|
opened.push(directory);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||||
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
|
|||||||
openDirectory: (directory) => {
|
openDirectory: (directory) => {
|
||||||
opened.push(directory);
|
opened.push(directory);
|
||||||
},
|
},
|
||||||
|
openOutsideDirectory: (directory) => {
|
||||||
|
opened.push(directory);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||||
opener.cancel();
|
opener.cancel();
|
||||||
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
|
|||||||
await pending;
|
await pending;
|
||||||
expect(opened).toEqual([]);
|
expect(opened).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||||
|
const { createInlinePathOpener } = await import("./path-open");
|
||||||
|
const workspaceFolders: string[] = [];
|
||||||
|
const outsideFolders: string[] = [];
|
||||||
|
const opener = createInlinePathOpener({
|
||||||
|
workspaceRoot: "/Users/me/repo",
|
||||||
|
client: null,
|
||||||
|
openFile: () => {},
|
||||||
|
openDirectory: (directory) => {
|
||||||
|
workspaceFolders.push(directory);
|
||||||
|
},
|
||||||
|
openOutsideDirectory: (directory) => {
|
||||||
|
outsideFolders.push(directory);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||||
|
await opener.open({ raw, path: raw }, "preferred");
|
||||||
|
}
|
||||||
|
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||||
|
expect(workspaceFolders).toEqual(["docs"]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,12 +14,23 @@ interface PathOpenInput {
|
|||||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
type PathOpenTarget =
|
||||||
|
| { kind: "file" }
|
||||||
|
| { kind: "directory"; directoryPath: string }
|
||||||
|
| { kind: "outsideDirectory"; directoryPath: string };
|
||||||
|
|
||||||
|
// Workspace paths arrive relative to the root; anything still absolute or
|
||||||
|
// home-relative lives outside it, where the Files panel cannot list it.
|
||||||
|
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||||
|
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||||
if (!input.file) {
|
if (!input.file) {
|
||||||
return { kind: "directory", directoryPath: input.directory };
|
return isOutsideWorkspacePath(input.directory)
|
||||||
|
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||||
|
: { kind: "directory", directoryPath: input.directory };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
input.lineStart !== undefined ||
|
input.lineStart !== undefined ||
|
||||||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
|
|||||||
client: PathOpenInput["client"];
|
client: PathOpenInput["client"];
|
||||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||||
openDirectory: (path: string) => void;
|
openDirectory: (path: string) => void;
|
||||||
|
openOutsideDirectory: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
|
|||||||
if (ownGeneration !== generation) return;
|
if (ownGeneration !== generation) return;
|
||||||
if (destination.kind === "directory") {
|
if (destination.kind === "directory") {
|
||||||
input.openDirectory(destination.directoryPath);
|
input.openDirectory(destination.directoryPath);
|
||||||
|
} else if (destination.kind === "outsideDirectory") {
|
||||||
|
input.openOutsideDirectory(destination.directoryPath);
|
||||||
} else if (normalized.file) {
|
} else if (normalized.file) {
|
||||||
const location = normalizeWorkspaceFileLocation({
|
const location = normalizeWorkspaceFileLocation({
|
||||||
path: normalized.file,
|
path: normalized.file,
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
|
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so
|
||||||
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
|
* expanded tool cards stay in place, with a gap between their border and the rail.
|
||||||
* hangs from that center so opened rows read as children of the line that opened them.
|
|
||||||
*/
|
*/
|
||||||
const RAIL_LEFT = 7;
|
const RAIL_LEFT = 1;
|
||||||
const RAIL_WIDTH = 1;
|
const RAIL_WIDTH = 1;
|
||||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MessagePreview, MessageSender } from "@/components/user-message";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||||
@@ -89,6 +90,7 @@ export type { InlinePathTarget } from "@/assistant-file-links";
|
|||||||
export type { AssistantForkTarget };
|
export type { AssistantForkTarget };
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
|
sender?: AgentMessage["sender"];
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -387,6 +389,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
|||||||
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
||||||
|
|
||||||
export const UserMessage = memo(function UserMessage({
|
export const UserMessage = memo(function UserMessage({
|
||||||
|
sender,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -473,7 +476,9 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
onPointerEnter={handlePointerEnter}
|
onPointerEnter={handlePointerEnter}
|
||||||
onPointerLeave={handlePointerLeave}
|
onPointerLeave={handlePointerLeave}
|
||||||
>
|
>
|
||||||
|
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||||||
<View style={userMessageStylesheet.bubble}>
|
<View style={userMessageStylesheet.bubble}>
|
||||||
|
<MessagePreview>
|
||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
<View style={imagePreviewContainerStyle}>
|
<View style={imagePreviewContainerStyle}>
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
@@ -509,6 +514,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
</MessagePreview>
|
||||||
</View>
|
</View>
|
||||||
{hasText ? (
|
{hasText ? (
|
||||||
<View
|
<View
|
||||||
@@ -541,7 +547,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface AssistantTurnFooterProps {
|
interface AssistantTurnFooterProps {
|
||||||
getContent: () => string;
|
getContent?: () => string;
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||||
@@ -641,10 +647,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={assistantTurnFooterStylesheet.container}>
|
<View style={assistantTurnFooterStylesheet.container}>
|
||||||
|
{getContent ? (
|
||||||
<TurnCopyButton
|
<TurnCopyButton
|
||||||
getContent={getContent}
|
getContent={getContent}
|
||||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||||
{primaryLabel ? (
|
{primaryLabel ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
@@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
|||||||
: undefined;
|
: undefined;
|
||||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||||
direction: "tail",
|
...planTimelineTailFetch(),
|
||||||
projection: "projected",
|
|
||||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { density, densityIconBox } from "@/styles/density";
|
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||||
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
|
|||||||
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
|
||||||
|
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
|
||||||
|
|
||||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<ThemedMoreVertical
|
<ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} />
|
||||||
size={14}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2705,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginRight: -6,
|
marginRight: -6,
|
||||||
},
|
},
|
||||||
projectKebabButton: {
|
projectKebabButton: {
|
||||||
width: 24,
|
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
|
||||||
height: 24,
|
// dots on the same rail as the 24px desktop box and the row at its 44px height.
|
||||||
|
width: { xs: 32, md: 24 },
|
||||||
|
height: { xs: 32, md: 24 },
|
||||||
|
marginRight: { xs: -4, md: 0 },
|
||||||
|
marginVertical: { xs: -4, md: 0 },
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform";
|
|||||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { densityIconProps } from "@/styles/density";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import {
|
import {
|
||||||
@@ -70,13 +71,11 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
|
|||||||
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
|
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
|
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
|
||||||
|
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
|
||||||
|
|
||||||
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
return (
|
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
|
||||||
<ThemedMoreVertical
|
|
||||||
size={14}
|
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarWorkspaceMenuProps {
|
export interface SidebarWorkspaceMenuProps {
|
||||||
@@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
trigger: {
|
trigger: {
|
||||||
padding: 2,
|
// Larger on compact so the kebab is an easy tap target.
|
||||||
|
width: { xs: 32, md: 18 },
|
||||||
|
height: { xs: 32, md: 18 },
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
|
// Center the compact box on the 20px title line it is anchored to.
|
||||||
|
marginTop: { xs: -6, md: 0 },
|
||||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||||
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
||||||
marginRight: -7,
|
marginRight: { xs: -14, md: -7 },
|
||||||
},
|
},
|
||||||
triggerHovered: {
|
triggerHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
|
|||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
|
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3";
|
||||||
|
|
||||||
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
|
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
|
||||||
|
surface3: (theme) => ({ color: theme.colors.surface3 }),
|
||||||
surface0: (theme) => ({ color: theme.colors.surface0 }),
|
surface0: (theme) => ({ color: theme.colors.surface0 }),
|
||||||
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
|
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||||
|
import { View, type LayoutChangeEvent } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EdgeFade } from "@/components/ui/edge-fade";
|
||||||
|
import { useProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
|
const PREVIEW_HEIGHT = 240;
|
||||||
|
|
||||||
|
export function MessagePreview({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
const overflowing = height > PREVIEW_HEIGHT;
|
||||||
|
const measure = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||||
|
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={!expanded && styles.preview}>
|
||||||
|
<View style={styles.body} onLayout={measure}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
{overflowing && !expanded ? (
|
||||||
|
<EdgeFade edge="bottom" solidFraction={0} backdrop="surface3" style={styles.fade} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{overflowing ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
style={[styles.toggle, expanded && styles.expandedToggle]}
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
onPress={toggle}
|
||||||
|
>
|
||||||
|
{t(expanded ? "turnSummary.showLess" : "turnSummary.showMore")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageSender({
|
||||||
|
sender,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
sender: AgentMessage["sender"];
|
||||||
|
serverId?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const host = sender.serverId ?? serverId;
|
||||||
|
const agent = useSessionStore((state) => {
|
||||||
|
const session = host ? state.sessions[host] : undefined;
|
||||||
|
return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id);
|
||||||
|
});
|
||||||
|
const ProviderIcon = useProviderIcon(agent?.provider ?? "", host);
|
||||||
|
const renderProviderIcon = useCallback(
|
||||||
|
(color: string) => <ProviderIcon size={16} color={color} />,
|
||||||
|
[ProviderIcon],
|
||||||
|
);
|
||||||
|
const openSender = useCallback(() => {
|
||||||
|
if (host) navigateToAgent({ serverId: host, agentId: sender.id });
|
||||||
|
}, [host, sender.id]);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={renderProviderIcon}
|
||||||
|
style={styles.sender}
|
||||||
|
disabled={!host}
|
||||||
|
onPress={openSender}
|
||||||
|
>
|
||||||
|
{t("turnSummary.sentBy", { name: agent?.title ?? sender.title ?? sender.id })}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
||||||
|
body: { flexShrink: 0 },
|
||||||
|
sender: { paddingRight: 0, borderRightWidth: 0 },
|
||||||
|
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||||
|
expandedToggle: { marginTop: theme.spacing[2] },
|
||||||
|
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||||
|
}));
|
||||||
@@ -2495,6 +2495,7 @@ function ComposerContentImpl({
|
|||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
defaultSendBehavior={activeSendBehavior}
|
defaultSendBehavior={activeSendBehavior}
|
||||||
|
sendShortcut={appSettings.sendShortcut}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SendShortcut } from "@/hooks/use-settings";
|
||||||
import {
|
import {
|
||||||
useComposerEditingSession,
|
useComposerEditingSession,
|
||||||
useComposerFullscreen,
|
useComposerFullscreen,
|
||||||
@@ -88,6 +89,7 @@ import {
|
|||||||
resolveAlternateSendAction,
|
resolveAlternateSendAction,
|
||||||
resolveComposerSurfacePresentation,
|
resolveComposerSurfacePresentation,
|
||||||
resolveComposerSurfaceText,
|
resolveComposerSurfaceText,
|
||||||
|
resolveMessageInputEnterAction,
|
||||||
resolveRetainedSelection,
|
resolveRetainedSelection,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
@@ -96,6 +98,11 @@ import {
|
|||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
|
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
|
||||||
|
const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]];
|
||||||
|
|
||||||
|
function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] {
|
||||||
|
return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS;
|
||||||
|
}
|
||||||
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
|
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
|
||||||
|
|
||||||
export interface AttachmentMenuItem {
|
export interface AttachmentMenuItem {
|
||||||
@@ -167,6 +174,7 @@ export interface MessageInputProps {
|
|||||||
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
|
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
|
||||||
* lives only in DEFAULT_CLIENT_SETTINGS. */
|
* lives only in DEFAULT_CLIENT_SETTINGS. */
|
||||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||||
|
sendShortcut: SendShortcut;
|
||||||
/** Callback for queue button when agent is running */
|
/** Callback for queue button when agent is running */
|
||||||
onQueue?: (payload: MessagePayload) => void;
|
onQueue?: (payload: MessagePayload) => void;
|
||||||
/** Optional handler used when submit button is in loading state. */
|
/** Optional handler used when submit button is in loading state. */
|
||||||
@@ -402,6 +410,9 @@ interface DesktopKeyPressContext {
|
|||||||
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
||||||
input: ComposerKeyPressEvent["input"];
|
input: ComposerKeyPressEvent["input"];
|
||||||
submitOnEnter: boolean;
|
submitOnEnter: boolean;
|
||||||
|
sendShortcut: SendShortcut;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||||
isSubmitDisabled: boolean;
|
isSubmitDisabled: boolean;
|
||||||
isSubmitLoading: boolean;
|
isSubmitLoading: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -428,12 +439,21 @@ function handleDesktopKeyPressImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.nativeEvent.key !== "Enter") return;
|
if (event.nativeEvent.key !== "Enter") return;
|
||||||
if (!ctx.submitOnEnter) return;
|
|
||||||
if (shiftKey) return;
|
const action = resolveMessageInputEnterAction({
|
||||||
|
submitOnEnter: ctx.submitOnEnter,
|
||||||
|
sendShortcut: ctx.sendShortcut,
|
||||||
|
shiftKey: shiftKey ?? false,
|
||||||
|
metaKey: metaKey ?? false,
|
||||||
|
ctrlKey: ctrlKey ?? false,
|
||||||
|
isAgentRunning: ctx.isAgentRunning,
|
||||||
|
onQueue: ctx.onQueue,
|
||||||
|
});
|
||||||
|
if (action === null) return;
|
||||||
|
|
||||||
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
|
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (metaKey || ctrlKey) {
|
if (action === "alternate-send") {
|
||||||
ctx.handleAlternateSendAction();
|
ctx.handleAlternateSendAction();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1132,7 @@ interface ResolvedMessageInputProps {
|
|||||||
voiceAgentId: string | undefined;
|
voiceAgentId: string | undefined;
|
||||||
isAgentRunning: boolean;
|
isAgentRunning: boolean;
|
||||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||||
|
sendShortcut: SendShortcut;
|
||||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||||
onSubmitLoadingPress: (() => void) | undefined;
|
onSubmitLoadingPress: (() => void) | undefined;
|
||||||
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
||||||
@@ -1159,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
|||||||
voiceAgentId: props.voiceAgentId,
|
voiceAgentId: props.voiceAgentId,
|
||||||
isAgentRunning: props.isAgentRunning ?? false,
|
isAgentRunning: props.isAgentRunning ?? false,
|
||||||
defaultSendBehavior: props.defaultSendBehavior,
|
defaultSendBehavior: props.defaultSendBehavior,
|
||||||
|
sendShortcut: props.sendShortcut,
|
||||||
onQueue: props.onQueue,
|
onQueue: props.onQueue,
|
||||||
onSubmitLoadingPress: props.onSubmitLoadingPress,
|
onSubmitLoadingPress: props.onSubmitLoadingPress,
|
||||||
onKeyPressCallback: props.onKeyPress,
|
onKeyPressCallback: props.onKeyPress,
|
||||||
@@ -1236,6 +1258,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
defaultSendBehavior,
|
defaultSendBehavior,
|
||||||
|
sendShortcut,
|
||||||
onQueue,
|
onQueue,
|
||||||
onSubmitLoadingPress,
|
onSubmitLoadingPress,
|
||||||
onKeyPressCallback,
|
onKeyPressCallback,
|
||||||
@@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
selectionRef.current,
|
selectionRef.current,
|
||||||
),
|
),
|
||||||
submitOnEnter: shouldSubmitOnEnter,
|
submitOnEnter: shouldSubmitOnEnter,
|
||||||
|
sendShortcut,
|
||||||
|
isAgentRunning,
|
||||||
|
onQueue,
|
||||||
isSubmitDisabled,
|
isSubmitDisabled,
|
||||||
isSubmitLoading,
|
isSubmitLoading,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
shouldHandleWebKeyPress,
|
shouldHandleWebKeyPress,
|
||||||
onKeyPressCallback,
|
onKeyPressCallback,
|
||||||
shouldSubmitOnEnter,
|
shouldSubmitOnEnter,
|
||||||
|
sendShortcut,
|
||||||
|
isAgentRunning,
|
||||||
|
onQueue,
|
||||||
isSubmitDisabled,
|
isSubmitDisabled,
|
||||||
isSubmitLoading,
|
isSubmitLoading,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
submitLabel={submitLabel}
|
submitLabel={submitLabel}
|
||||||
submitButtonTestID={submitButtonTestID}
|
submitButtonTestID={submitButtonTestID}
|
||||||
buttonIconSize={buttonIconSize}
|
buttonIconSize={buttonIconSize}
|
||||||
sendKeys={DEFAULT_SEND_KEYS}
|
sendKeys={resolveSendKeys(sendShortcut)}
|
||||||
sendTooltipLabel={sendTooltipLabel}
|
sendTooltipLabel={sendTooltipLabel}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
submitLabel,
|
submitLabel,
|
||||||
submitButtonTestID,
|
submitButtonTestID,
|
||||||
buttonIconSize,
|
buttonIconSize,
|
||||||
|
sendShortcut,
|
||||||
sendTooltipLabel,
|
sendTooltipLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyDictationTranscript,
|
applyDictationTranscript,
|
||||||
computeCanStartDictation,
|
computeCanStartDictation,
|
||||||
|
type MessageInputEnterContext,
|
||||||
resolveActiveSendBehavior,
|
resolveActiveSendBehavior,
|
||||||
resolveComposerSurfacePresentation,
|
resolveComposerSurfacePresentation,
|
||||||
resolveComposerSurfaceText,
|
resolveComposerSurfaceText,
|
||||||
resolveGlyphRailOffset,
|
resolveGlyphRailOffset,
|
||||||
|
resolveMessageInputEnterAction,
|
||||||
resolveRetainedSelection,
|
resolveRetainedSelection,
|
||||||
resolveAlternateSendAction,
|
resolveAlternateSendAction,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
@@ -320,6 +322,212 @@ describe("composer send behavior", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveMessageInputEnterAction", () => {
|
||||||
|
function enterContext(
|
||||||
|
overrides: Partial<MessageInputEnterContext> = {},
|
||||||
|
): MessageInputEnterContext {
|
||||||
|
return {
|
||||||
|
submitOnEnter: true,
|
||||||
|
sendShortcut: "enter",
|
||||||
|
shiftKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
isAgentRunning: false,
|
||||||
|
onQueue: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => {
|
||||||
|
expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns null when modifier Enter sends and submit on enter is disabled", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends on plain Enter when Enter sends", () => {
|
||||||
|
expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for Shift+Enter when Enter sends", () => {
|
||||||
|
expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => {
|
||||||
|
expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send");
|
||||||
|
expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })),
|
||||||
|
).toBe("alternate-send");
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })),
|
||||||
|
).toBe("alternate-send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }),
|
||||||
|
),
|
||||||
|
).toBe("default-send");
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }),
|
||||||
|
),
|
||||||
|
).toBe("default-send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for plain and Shift+Enter when modifier Enter sends", () => {
|
||||||
|
expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })),
|
||||||
|
).toBe("default-send");
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })),
|
||||||
|
).toBe("default-send");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default action on modifier Enter while running when modifier Enter sends", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
|
||||||
|
),
|
||||||
|
).toBe("default-send");
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }),
|
||||||
|
),
|
||||||
|
).toBe("default-send");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composed with the send actions", () => {
|
||||||
|
function actions() {
|
||||||
|
const calls: string[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
handleSendMessage: () => calls.push("send"),
|
||||||
|
handleQueueMessage: () => calls.push("queue"),
|
||||||
|
onQueue: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCommandEnterToSendModifierEnter() {
|
||||||
|
return resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("queues through the default send action when queue behavior is selected and the agent is running", () => {
|
||||||
|
const sendAction = actions();
|
||||||
|
|
||||||
|
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
|
||||||
|
|
||||||
|
runDefaultSendAction({
|
||||||
|
defaultSendBehavior: "queue",
|
||||||
|
isAgentRunning: true,
|
||||||
|
onQueue: sendAction.onQueue,
|
||||||
|
handleSendMessage: sendAction.handleSendMessage,
|
||||||
|
handleQueueMessage: sendAction.handleQueueMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendAction.calls).toEqual(["queue"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends through the default send action when queue behavior is selected and the agent is idle", () => {
|
||||||
|
const sendAction = actions();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMessageInputEnterAction(
|
||||||
|
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }),
|
||||||
|
),
|
||||||
|
).toBe("default-send");
|
||||||
|
|
||||||
|
runDefaultSendAction({
|
||||||
|
defaultSendBehavior: "queue",
|
||||||
|
isAgentRunning: false,
|
||||||
|
onQueue: sendAction.onQueue,
|
||||||
|
handleSendMessage: sendAction.handleSendMessage,
|
||||||
|
handleQueueMessage: sendAction.handleQueueMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendAction.calls).toEqual(["send"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)(
|
||||||
|
"sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running",
|
||||||
|
({ defaultSendBehavior }) => {
|
||||||
|
const sendAction = actions();
|
||||||
|
|
||||||
|
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
|
||||||
|
|
||||||
|
runDefaultSendAction({
|
||||||
|
defaultSendBehavior,
|
||||||
|
isAgentRunning: true,
|
||||||
|
onQueue: sendAction.onQueue,
|
||||||
|
handleSendMessage: sendAction.handleSendMessage,
|
||||||
|
handleQueueMessage: sendAction.handleQueueMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendAction.calls).toEqual(["send"]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stopRealtimeVoice", () => {
|
describe("stopRealtimeVoice", () => {
|
||||||
it("keeps voice mode active when the running agent refuses cancellation", async () => {
|
it("keeps voice mode active when the running agent refuses cancellation", async () => {
|
||||||
const cancellationError = new Error("active run cancellation was not acknowledged");
|
const cancellationError = new Error("active run cancellation was not acknowledged");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SendShortcut } from "@/hooks/use-settings";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||||
import type { MessagePayload, TextReplacement } from "@/composer/types";
|
import type { MessagePayload, TextReplacement } from "@/composer/types";
|
||||||
@@ -151,6 +152,29 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
|
|||||||
runDefaultSendAction(ctx);
|
runDefaultSendAction(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MessageInputEnterAction = "alternate-send" | "default-send";
|
||||||
|
|
||||||
|
export interface MessageInputEnterContext {
|
||||||
|
submitOnEnter: boolean;
|
||||||
|
sendShortcut: SendShortcut;
|
||||||
|
shiftKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMessageInputEnterAction(
|
||||||
|
ctx: MessageInputEnterContext,
|
||||||
|
): MessageInputEnterAction | null {
|
||||||
|
if (!ctx.submitOnEnter) return null;
|
||||||
|
if (ctx.shiftKey) return null;
|
||||||
|
const hasModifier = ctx.metaKey || ctx.ctrlKey;
|
||||||
|
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
|
||||||
|
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
|
||||||
|
return "default-send";
|
||||||
|
}
|
||||||
|
|
||||||
export function runMessageInputKeyboardAction(
|
export function runMessageInputKeyboardAction(
|
||||||
action: MessageInputKeyboardActionKind,
|
action: MessageInputKeyboardActionKind,
|
||||||
actions: MessageInputKeyboardActions,
|
actions: MessageInputKeyboardActions,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The diff shapes the layout benchmarks measure, shared by the node bench
|
||||||
|
* (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`)
|
||||||
|
* so both report against the same text.
|
||||||
|
*/
|
||||||
|
export interface BenchmarkCorpusFile {
|
||||||
|
path: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function repeatToLength(unit: string, length: number): string {
|
||||||
|
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A real source file: short lines, a few with ligature punctuation. */
|
||||||
|
export function realCodeCorpusFile(): BenchmarkCorpusFile {
|
||||||
|
const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8");
|
||||||
|
return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */
|
||||||
|
export function changelogCorpusFile(): BenchmarkCorpusFile {
|
||||||
|
const sentence =
|
||||||
|
"- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. ";
|
||||||
|
return {
|
||||||
|
path: "CHANGELOG.md",
|
||||||
|
lines: Array.from({ length: 42 }, (_, index) =>
|
||||||
|
repeatToLength(sentence, 200 + ((index * 97) % 398)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */
|
||||||
|
export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||||
|
return {
|
||||||
|
path: "src/generated.ts",
|
||||||
|
lines: Array.from({ length: lineCount }, (_, index) => {
|
||||||
|
const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`;
|
||||||
|
return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized data: no ligatures, every line wraps several times. */
|
||||||
|
export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||||
|
return {
|
||||||
|
path: "fixtures/data.json",
|
||||||
|
lines: Array.from({ length: lineCount }, (_, index) =>
|
||||||
|
repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pull-request-sized working diff: one of each shape above. */
|
||||||
|
export function pullRequestCorpus(): BenchmarkCorpusFile[] {
|
||||||
|
return [
|
||||||
|
realCodeCorpusFile(),
|
||||||
|
changelogCorpusFile(),
|
||||||
|
generatedCodeCorpusFile(1200),
|
||||||
|
jsonCorpusFile(300),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||||
|
import { bench, describe } from "vitest";
|
||||||
|
import {
|
||||||
|
changelogCorpusFile,
|
||||||
|
generatedCodeCorpusFile,
|
||||||
|
jsonCorpusFile,
|
||||||
|
pullRequestCorpus,
|
||||||
|
realCodeCorpusFile,
|
||||||
|
type BenchmarkCorpusFile,
|
||||||
|
} from "./benchmark-corpus";
|
||||||
|
import { buildDiffDocumentModel } from "./model";
|
||||||
|
import {
|
||||||
|
createAdditiveAdvances,
|
||||||
|
createChunkedAdvanceMeasurer,
|
||||||
|
createChunkedWidthMeasurer,
|
||||||
|
createMeasuredAdvances,
|
||||||
|
requiresShaping,
|
||||||
|
} from "./text-measurement";
|
||||||
|
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||||
|
|
||||||
|
// The pure layout work the diff surface does when a diff opens or line wrap is
|
||||||
|
// toggled, without a browser or Skia. Counts are the backend-independent cost
|
||||||
|
// owners (how many strings a shaper has to see), timings are the JS overhead
|
||||||
|
// around them:
|
||||||
|
// npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||||
|
|
||||||
|
const DESKTOP_VIEWPORT = { width: 1400, height: 900 };
|
||||||
|
const PHONE_VIEWPORT = { width: 390, height: 800 };
|
||||||
|
const CELL_WIDTH = 7.8;
|
||||||
|
|
||||||
|
interface MeasurementStats {
|
||||||
|
/** Whole strings handed to the backend. */
|
||||||
|
calls: number;
|
||||||
|
/** Characters inside those strings; canvas and Skia cost scales with this. */
|
||||||
|
characters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Backend {
|
||||||
|
measurer: TextMeasurer;
|
||||||
|
stats: MeasurementStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monospaceWidth(text: string): number {
|
||||||
|
let width = 0;
|
||||||
|
for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1;
|
||||||
|
return width * CELL_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countingMeasure(stats: MeasurementStats): (text: string) => number {
|
||||||
|
return (text) => {
|
||||||
|
stats.calls += 1;
|
||||||
|
stats.characters += text.length;
|
||||||
|
return monospaceWidth(text);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Previous web backend, retained to isolate the cost of width-only probes. */
|
||||||
|
function webBackendWithoutWidthProbe(): Backend {
|
||||||
|
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||||
|
const measure = countingMeasure(stats);
|
||||||
|
return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter.
|
||||||
|
* Width-only probes shape one string per chunk, like the native measurer; the
|
||||||
|
* widths are the same sums, in the same order, as the last cumulative advance.
|
||||||
|
*/
|
||||||
|
function webBackend(): Backend {
|
||||||
|
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||||
|
const measure = countingMeasure(stats);
|
||||||
|
const additive = createAdditiveAdvances(measure);
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
measurer: {
|
||||||
|
measure,
|
||||||
|
measureAdvances: createMeasuredAdvances(measure),
|
||||||
|
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||||
|
const text = graphemes.join("");
|
||||||
|
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */
|
||||||
|
function nativeBackend(): Backend {
|
||||||
|
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||||
|
const widths = new Map<string, number>();
|
||||||
|
const additive = (graphemes: readonly string[]) => {
|
||||||
|
let advance = 0;
|
||||||
|
return graphemes.map((grapheme) => {
|
||||||
|
let width = widths.get(grapheme);
|
||||||
|
if (width === undefined) {
|
||||||
|
stats.calls += 1;
|
||||||
|
stats.characters += grapheme.length;
|
||||||
|
width = monospaceWidth(grapheme);
|
||||||
|
widths.set(grapheme, width);
|
||||||
|
}
|
||||||
|
return (advance += width);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const shape = countingMeasure(stats);
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
measurer: {
|
||||||
|
measure(text) {
|
||||||
|
return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0);
|
||||||
|
},
|
||||||
|
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||||
|
const text = graphemes.join("");
|
||||||
|
return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0);
|
||||||
|
}),
|
||||||
|
measureAdvances: createChunkedAdvanceMeasurer({
|
||||||
|
requiresShaping,
|
||||||
|
measureAdditive: additive,
|
||||||
|
measureShaped(graphemes) {
|
||||||
|
shape(graphemes.join(""));
|
||||||
|
let advance = 0;
|
||||||
|
return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme)));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile {
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
isNew: true,
|
||||||
|
isDeleted: false,
|
||||||
|
additions: file.lines.length,
|
||||||
|
deletions: 0,
|
||||||
|
hunks: [
|
||||||
|
{
|
||||||
|
oldStart: 0,
|
||||||
|
oldCount: 0,
|
||||||
|
newStart: 1,
|
||||||
|
newCount: file.lines.length,
|
||||||
|
lines: file.lines.map((content) => ({
|
||||||
|
type: "add" as const,
|
||||||
|
content,
|
||||||
|
tokens: [{ text: content, style: null }],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scenario {
|
||||||
|
name: string;
|
||||||
|
files: ParsedDiffFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENARIOS: Scenario[] = [
|
||||||
|
{ name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] },
|
||||||
|
{ name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] },
|
||||||
|
{ name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] },
|
||||||
|
{ name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] },
|
||||||
|
{ name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) },
|
||||||
|
];
|
||||||
|
|
||||||
|
function input(
|
||||||
|
scenario: Scenario,
|
||||||
|
options: {
|
||||||
|
wrapLines: boolean;
|
||||||
|
viewport: { width: number; height: number };
|
||||||
|
measureText: TextMeasurer;
|
||||||
|
},
|
||||||
|
): BuildDiffDocumentModelInput {
|
||||||
|
return {
|
||||||
|
files: scenario.files,
|
||||||
|
collapsedFilePaths: new Set(),
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: options.wrapLines,
|
||||||
|
viewportWidth: options.viewport.width,
|
||||||
|
typography: { family: "monospace", size: 13, lineHeight: 20 },
|
||||||
|
measureText: options.measureText,
|
||||||
|
palette: {
|
||||||
|
surface: "#000",
|
||||||
|
headerSurface: "#111",
|
||||||
|
border: "#222",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
addition: "green",
|
||||||
|
deletion: "red",
|
||||||
|
additionBackground: "#010",
|
||||||
|
deletionBackground: "#100",
|
||||||
|
emptyBackground: "#111",
|
||||||
|
selection: "blue",
|
||||||
|
headerActiveSurface: "#222",
|
||||||
|
headerBorder: "#333",
|
||||||
|
statusSuccess: "green",
|
||||||
|
statusDanger: "red",
|
||||||
|
statusWarning: "orange",
|
||||||
|
syntax: {},
|
||||||
|
},
|
||||||
|
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||||
|
// What the surfaces pass at scrollTop 0: five viewports tall.
|
||||||
|
materializationWindow: { top: 0, height: options.viewport.height * 5 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Variant {
|
||||||
|
name: string;
|
||||||
|
wrapLines: boolean;
|
||||||
|
viewport: { width: number; height: number };
|
||||||
|
backend: () => Backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANTS: Variant[] = [
|
||||||
|
{
|
||||||
|
name: "unwrapped desktop | web",
|
||||||
|
wrapLines: false,
|
||||||
|
viewport: DESKTOP_VIEWPORT,
|
||||||
|
backend: webBackend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped desktop | web",
|
||||||
|
wrapLines: true,
|
||||||
|
viewport: DESKTOP_VIEWPORT,
|
||||||
|
backend: webBackend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped desktop | web without width probe",
|
||||||
|
wrapLines: true,
|
||||||
|
viewport: DESKTOP_VIEWPORT,
|
||||||
|
backend: webBackendWithoutWidthProbe,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped phone | web",
|
||||||
|
wrapLines: true,
|
||||||
|
viewport: PHONE_VIEWPORT,
|
||||||
|
backend: webBackend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unwrapped desktop | native",
|
||||||
|
wrapLines: false,
|
||||||
|
viewport: DESKTOP_VIEWPORT,
|
||||||
|
backend: nativeBackend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped desktop | native",
|
||||||
|
wrapLines: true,
|
||||||
|
viewport: DESKTOP_VIEWPORT,
|
||||||
|
backend: nativeBackend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped phone | native",
|
||||||
|
wrapLines: true,
|
||||||
|
viewport: PHONE_VIEWPORT,
|
||||||
|
backend: nativeBackend,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function countModel(scenario: Scenario, variant: Variant) {
|
||||||
|
const { measurer, stats } = variant.backend();
|
||||||
|
const started = performance.now();
|
||||||
|
const model = buildDiffDocumentModel(
|
||||||
|
input(scenario, {
|
||||||
|
wrapLines: variant.wrapLines,
|
||||||
|
viewport: variant.viewport,
|
||||||
|
measureText: measurer,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const elapsedMs = performance.now() - started;
|
||||||
|
let materializedCells = 0;
|
||||||
|
let fragments = 0;
|
||||||
|
let graphemes = 0;
|
||||||
|
for (const row of model.rows) {
|
||||||
|
if (row.kind !== "line") continue;
|
||||||
|
for (const cell of row.cells) {
|
||||||
|
if (!cell || cell.fragments.length === 0) continue;
|
||||||
|
materializedCells += 1;
|
||||||
|
fragments += cell.fragments.length;
|
||||||
|
for (const fragment of cell.fragments) graphemes += fragment.graphemes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sourceLines = scenario.files.reduce(
|
||||||
|
(count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sourceLines,
|
||||||
|
materializedCells,
|
||||||
|
fragments,
|
||||||
|
graphemeObjects: graphemes,
|
||||||
|
measureCalls: stats.calls,
|
||||||
|
measuredCharacters: stats.characters,
|
||||||
|
coldMs: Number(elapsedMs.toFixed(1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const report: Record<string, ReturnType<typeof countModel>> = {};
|
||||||
|
for (const scenario of SCENARIOS) {
|
||||||
|
for (const variant of VARIANTS) {
|
||||||
|
report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.table(report);
|
||||||
|
|
||||||
|
for (const scenario of SCENARIOS) {
|
||||||
|
describe(scenario.name, () => {
|
||||||
|
for (const variant of VARIANTS) {
|
||||||
|
if (variant.backend === nativeBackend) continue;
|
||||||
|
bench(variant.name, () => {
|
||||||
|
buildDiffDocumentModel(
|
||||||
|
input(scenario, {
|
||||||
|
wrapLines: variant.wrapLines,
|
||||||
|
viewport: variant.viewport,
|
||||||
|
measureText: variant.backend().measurer,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
resolveRelayoutScrollTop,
|
resolveRelayoutScrollTop,
|
||||||
shouldApplyRelayoutScroll,
|
shouldApplyRelayoutScroll,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types";
|
||||||
|
|
||||||
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
|
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
|
||||||
|
|
||||||
@@ -639,17 +639,119 @@ describe("diff document model", () => {
|
|||||||
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
|
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps wrapped geometry fully measured", () => {
|
it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => {
|
||||||
|
const changed = fileWithChangedLine("x".repeat(100));
|
||||||
|
const files = [changed, file("later.ts")];
|
||||||
|
// Deliberately inaccurate M width makes the estimate differ from exact layout.
|
||||||
|
const measureText = {
|
||||||
|
measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10),
|
||||||
|
};
|
||||||
|
const options = input({ files, measureText });
|
||||||
|
const first = buildDiffDocumentModel({
|
||||||
|
...options,
|
||||||
|
materializationWindow: { top: 30, height: 1 },
|
||||||
|
});
|
||||||
|
const eager = buildDiffDocumentModel(options);
|
||||||
|
const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] });
|
||||||
|
expect(first.height).not.toBe(eager.height);
|
||||||
|
expect(exact.rows).toEqual(eager.rows);
|
||||||
|
expect(exact.height).toBe(eager.height);
|
||||||
|
expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path));
|
||||||
|
const scrollTop = first.files[1]!.bodyTop + 3;
|
||||||
|
const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop);
|
||||||
|
expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3);
|
||||||
|
expect(captureScrollAnchor(exact, nextScrollTop)).toEqual(
|
||||||
|
captureScrollAnchor(first, scrollTop),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => {
|
||||||
|
const source = file();
|
||||||
|
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||||
|
type: "add" as const,
|
||||||
|
content: "x".repeat(200),
|
||||||
|
}));
|
||||||
|
const options = input({ files: [source], viewportWidth: 500 });
|
||||||
|
const before = buildDiffDocumentModel(options);
|
||||||
|
const scrollTop = before.rows[100]!.top + 36;
|
||||||
|
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||||
|
expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 });
|
||||||
|
const resizedOptions = { ...options, viewportWidth: 180 };
|
||||||
|
const estimated = buildDiffDocumentModel({
|
||||||
|
...resizedOptions,
|
||||||
|
materializationWindow: {
|
||||||
|
top: Math.floor(scrollTop / 800) * 800 - 800,
|
||||||
|
height: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const estimatedRow = estimated.rows[100]!;
|
||||||
|
expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]);
|
||||||
|
const restored = resolveRelayoutScrollTop(before, estimated, scrollTop);
|
||||||
|
const recaptured = captureScrollAnchor(estimated, restored)!;
|
||||||
|
expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 });
|
||||||
|
|
||||||
|
const materialized = buildDiffDocumentModel({
|
||||||
|
...resizedOptions,
|
||||||
|
materializationWindow: { top: restored - 800, height: 2000 },
|
||||||
|
reuseFrom: [estimated],
|
||||||
|
});
|
||||||
|
const eager = buildDiffDocumentModel(resizedOptions);
|
||||||
|
const expected = resolveRelayoutScrollTop(before, eager, scrollTop);
|
||||||
|
expect(expected).toBe(28_938);
|
||||||
|
expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight);
|
||||||
|
expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => {
|
||||||
|
const source = file();
|
||||||
|
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||||
|
type: "add" as const,
|
||||||
|
content: "x".repeat(200),
|
||||||
|
}));
|
||||||
|
const options = input({ files: [source], viewportWidth: 500 });
|
||||||
|
const before = buildDiffDocumentModel(options);
|
||||||
|
const scrollTop = before.rows[100]!.top + 38;
|
||||||
|
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||||
|
const resizedOptions = {
|
||||||
|
...options,
|
||||||
|
viewportWidth: 180,
|
||||||
|
measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) },
|
||||||
|
};
|
||||||
|
const resized = buildDiffDocumentModel({
|
||||||
|
...resizedOptions,
|
||||||
|
materializationWindow: { top: scrollTop - 800, height: 2000 },
|
||||||
|
materializationAnchor: anchor,
|
||||||
|
});
|
||||||
|
const row = resized.rows[100]!;
|
||||||
|
expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0);
|
||||||
|
const restored = resolveRelayoutScrollTop(before, resized, scrollTop);
|
||||||
|
const materialized = buildDiffDocumentModel({
|
||||||
|
...resizedOptions,
|
||||||
|
materializationWindow: { top: restored - 800, height: 2000 },
|
||||||
|
materializationAnchor: captureScrollAnchor(resized, restored),
|
||||||
|
reuseFrom: [resized],
|
||||||
|
});
|
||||||
|
const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored);
|
||||||
|
const nextAnchor = captureScrollAnchor(materialized, nextScrollTop);
|
||||||
|
// Offset 90 belongs to the fragment beginning at 78 after narrowing.
|
||||||
|
expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 });
|
||||||
|
expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes wrapped text only inside the window", () => {
|
||||||
const model = buildDiffDocumentModel(
|
const model = buildDiffDocumentModel(
|
||||||
input({
|
input({
|
||||||
files: [file("a.ts"), file("b.ts")],
|
files: [file("a.ts"), file("b.ts")],
|
||||||
wrapLines: true,
|
wrapLines: true,
|
||||||
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
|
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
|
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
|
||||||
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
|
expect(measuredFragments(model, "b.ts")).toBe(0);
|
||||||
|
expect(
|
||||||
|
model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)),
|
||||||
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -798,3 +900,7 @@ function reviewActionsWithEditor(
|
|||||||
onDeleteComment() {},
|
onDeleteComment() {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFragments(cell: DiffCell | null): boolean {
|
||||||
|
return cell !== null && cell.fragments.length > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,27 +154,38 @@ function appendReusableFileRows(candidate: {
|
|||||||
}): FileRowsResult {
|
}): FileRowsResult {
|
||||||
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
||||||
const retainsRowGeometry =
|
const retainsRowGeometry =
|
||||||
!candidate.input.wrapLines &&
|
|
||||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||||
candidate.file.top === candidate.fileTop &&
|
candidate.file.top === candidate.fileTop &&
|
||||||
candidate.file.fileIndex === candidate.fileIndex &&
|
candidate.file.fileIndex === candidate.fileIndex &&
|
||||||
candidate.file.rowStart === candidate.rows.length;
|
candidate.file.rowStart === candidate.rows.length;
|
||||||
let top = candidate.bodyTop;
|
let top = candidate.bodyTop;
|
||||||
for (const reusedRow of reusedRows) {
|
for (const reusedRow of reusedRows) {
|
||||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
let nextRow =
|
||||||
|
retainsRowGeometry && reusedRow.top === top
|
||||||
|
? reusedRow
|
||||||
|
: repositionReusedRow(reusedRow, candidate, top);
|
||||||
if (
|
if (
|
||||||
nextRow.kind === "line" &&
|
nextRow.kind === "line" &&
|
||||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
(intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) ||
|
||||||
|
containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) &&
|
||||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||||
) {
|
) {
|
||||||
nextRow = {
|
const cells = materializeCells(
|
||||||
...nextRow,
|
|
||||||
cells: materializeCells(
|
|
||||||
nextRow.cells,
|
nextRow.cells,
|
||||||
candidate.file.file,
|
candidate.file.file,
|
||||||
candidate.file.gutterWidth,
|
candidate.file.gutterWidth,
|
||||||
candidate.input,
|
candidate.input,
|
||||||
|
);
|
||||||
|
nextRow = {
|
||||||
|
...nextRow,
|
||||||
|
cells,
|
||||||
|
height:
|
||||||
|
Math.max(
|
||||||
|
candidate.input.typography.lineHeight,
|
||||||
|
...cells.map(
|
||||||
|
(cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight,
|
||||||
),
|
),
|
||||||
|
) + nextRow.reviewHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
candidate.rows.push(nextRow);
|
candidate.rows.push(nextRow);
|
||||||
@@ -240,6 +251,7 @@ function appendNewFileRows(candidate: {
|
|||||||
const lines = geometryLines(
|
const lines = geometryLines(
|
||||||
lineSources(candidate.file, candidate.input.layout, false),
|
lineSources(candidate.file, candidate.input.layout, false),
|
||||||
candidate.input,
|
candidate.input,
|
||||||
|
candidate.gutterWidth,
|
||||||
);
|
);
|
||||||
const fileBottom =
|
const fileBottom =
|
||||||
candidate.bodyTop +
|
candidate.bodyTop +
|
||||||
@@ -252,8 +264,8 @@ function appendNewFileRows(candidate: {
|
|||||||
let maximumHorizontalOverflow = 0;
|
let maximumHorizontalOverflow = 0;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const shouldMaterialize =
|
const shouldMaterialize =
|
||||||
candidate.input.wrapLines ||
|
intersectsMaterializationWindow(candidate.input, top, top + line.height) ||
|
||||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells);
|
||||||
const cells = shouldMaterialize
|
const cells = shouldMaterialize
|
||||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||||
: line.cells;
|
: line.cells;
|
||||||
@@ -275,7 +287,7 @@ function appendNewFileRows(candidate: {
|
|||||||
candidate.input.typography.lineHeight,
|
candidate.input.typography.lineHeight,
|
||||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||||
);
|
);
|
||||||
const height = textHeight + line.reviewHeight;
|
const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height;
|
||||||
candidate.rows.push({
|
candidate.rows.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
index: candidate.rows.length,
|
index: candidate.rows.length,
|
||||||
@@ -412,13 +424,14 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function geometryCell(source: CellSource): DiffCell {
|
function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell {
|
||||||
return {
|
return {
|
||||||
type: source.type,
|
type: source.type,
|
||||||
content: source.content,
|
content: source.content,
|
||||||
lineNumber: source.lineNumber,
|
lineNumber: source.lineNumber,
|
||||||
tokens: [],
|
tokens: [],
|
||||||
fragments: [],
|
fragments: [],
|
||||||
|
estimatedWrapColumns,
|
||||||
reviewTarget: source.reviewTarget,
|
reviewTarget: source.reviewTarget,
|
||||||
sourceIdentity: source.sourceIdentity,
|
sourceIdentity: source.sourceIdentity,
|
||||||
};
|
};
|
||||||
@@ -427,20 +440,87 @@ function geometryCell(source: CellSource): DiffCell {
|
|||||||
function geometryLines(
|
function geometryLines(
|
||||||
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
||||||
input: BuildDiffDocumentModelInput,
|
input: BuildDiffDocumentModelInput,
|
||||||
|
gutterWidth: number,
|
||||||
): GeometryLine[] {
|
): GeometryLine[] {
|
||||||
|
const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0;
|
||||||
return sources.map((sourceCells) => {
|
return sources.map((sourceCells) => {
|
||||||
|
const columns = input.wrapLines
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
(input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) /
|
||||||
|
characterWidth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Infinity;
|
||||||
const cells = sourceCells.map((source) =>
|
const cells = sourceCells.map((source) =>
|
||||||
source ? geometryCell(source) : null,
|
source ? geometryCell(source, columns) : null,
|
||||||
) as DiffLineRow["cells"];
|
) as DiffLineRow["cells"];
|
||||||
const reviewHeight = reviewHeightForCells(cells, input);
|
const reviewHeight = reviewHeightForCells(cells, input);
|
||||||
return {
|
return {
|
||||||
cells,
|
cells,
|
||||||
reviewHeight,
|
reviewHeight,
|
||||||
height: input.typography.lineHeight + reviewHeight,
|
// Unseen wrapped rows use a monospace estimate; materialization replaces
|
||||||
|
// it with the exact fragment count without changing source identity.
|
||||||
|
height:
|
||||||
|
input.typography.lineHeight *
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
...cells.map((cell) => {
|
||||||
|
return cell && input.wrapLines
|
||||||
|
? Math.ceil(estimatedDisplayOffset(cell.content) / columns)
|
||||||
|
: 1;
|
||||||
|
}),
|
||||||
|
) +
|
||||||
|
reviewHeight,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the same tab expansion and column count for estimated heights and anchors.
|
||||||
|
function estimatedDisplayOffset(text: string): number {
|
||||||
|
return text.length + (text.match(/\t/g)?.length ?? 0) * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number {
|
||||||
|
return Math.floor(
|
||||||
|
estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) /
|
||||||
|
(cell.estimatedWrapColumns ?? Infinity),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) {
|
||||||
|
const columns = cell.estimatedWrapColumns ?? Infinity;
|
||||||
|
const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1);
|
||||||
|
const index = Math.min(visualIndex, lastIndex);
|
||||||
|
const displayOffset = index === 0 ? 0 : index * columns;
|
||||||
|
let start = Math.min(displayOffset, cell.content.length);
|
||||||
|
if (cell.content.includes("\t")) {
|
||||||
|
let offset = 0;
|
||||||
|
start = 0;
|
||||||
|
while (start < cell.content.length && offset < displayOffset) {
|
||||||
|
offset += cell.content[start] === "\t" ? 4 : 1;
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start, top: index * lineHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resize can move the anchor outside the old window. Measure that row in the
|
||||||
|
// same pass: estimated columns cannot identify exact shaped-text fragments.
|
||||||
|
function containsMaterializationAnchor(
|
||||||
|
input: BuildDiffDocumentModelInput,
|
||||||
|
path: string,
|
||||||
|
cells: DiffLineRow["cells"],
|
||||||
|
): boolean {
|
||||||
|
const anchor = input.materializationAnchor;
|
||||||
|
return (
|
||||||
|
anchor?.kind === "text" &&
|
||||||
|
anchor.path === path &&
|
||||||
|
cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function intersectsMaterializationWindow(
|
function intersectsMaterializationWindow(
|
||||||
input: BuildDiffDocumentModelInput,
|
input: BuildDiffDocumentModelInput,
|
||||||
fileTop: number,
|
fileTop: number,
|
||||||
@@ -795,17 +875,27 @@ export function captureScrollAnchor(
|
|||||||
}
|
}
|
||||||
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
||||||
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
||||||
let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex]));
|
let cellIndex = row.cells.findIndex(
|
||||||
|
(cell) =>
|
||||||
|
cell &&
|
||||||
|
(cell.fragments.length > 0
|
||||||
|
? Boolean(cell.fragments[visualIndex])
|
||||||
|
: estimatedFragment(cell, visualIndex, model.lineHeight).top ===
|
||||||
|
visualIndex * model.lineHeight),
|
||||||
|
);
|
||||||
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
||||||
const cell = row.cells[cellIndex];
|
const cell = row.cells[cellIndex];
|
||||||
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
||||||
const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)];
|
const fragment =
|
||||||
const anchorY = row.top + (fragment?.top ?? 0);
|
cell.fragments.length > 0
|
||||||
|
? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]!
|
||||||
|
: estimatedFragment(cell, visualIndex, model.lineHeight);
|
||||||
|
const anchorY = row.top + fragment.top;
|
||||||
return {
|
return {
|
||||||
kind: "text",
|
kind: "text",
|
||||||
path: file.path,
|
path: file.path,
|
||||||
sourceIdentity: cell.sourceIdentity,
|
sourceIdentity: cell.sourceIdentity,
|
||||||
cellOffset: fragment?.start ?? 0,
|
cellOffset: fragment.start,
|
||||||
viewportDelta: anchorY - scrollTop,
|
viewportDelta: anchorY - scrollTop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -831,8 +921,10 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
|
|||||||
const fragment =
|
const fragment =
|
||||||
cell.fragments.find(
|
cell.fragments.find(
|
||||||
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
||||||
) ?? cell.fragments.at(-1);
|
) ??
|
||||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
cell.fragments.at(-1) ??
|
||||||
|
estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight);
|
||||||
|
return Math.max(0, row.top + fragment.top - anchor.viewportDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRelayoutScrollTop(
|
export function resolveRelayoutScrollTop(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from "./horizontal-offsets";
|
} from "./horizontal-offsets";
|
||||||
import {
|
import {
|
||||||
buildDiffDocumentModel,
|
buildDiffDocumentModel,
|
||||||
|
captureScrollAnchor,
|
||||||
resolveRelayoutScrollTop,
|
resolveRelayoutScrollTop,
|
||||||
retainReusableModels,
|
retainReusableModels,
|
||||||
shouldApplyRelayoutScroll,
|
shouldApplyRelayoutScroll,
|
||||||
@@ -138,6 +139,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||||
},
|
},
|
||||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||||
|
materializationAnchor: previousModelRef.current
|
||||||
|
? captureScrollAnchor(previousModelRef.current, scrollTop.get())
|
||||||
|
: null,
|
||||||
reuseFrom,
|
reuseFrom,
|
||||||
});
|
});
|
||||||
reusableModelRef.current = {
|
reusableModelRef.current = {
|
||||||
@@ -148,6 +152,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
}, [
|
}, [
|
||||||
measurement,
|
measurement,
|
||||||
fileWindowTop,
|
fileWindowTop,
|
||||||
|
scrollTop,
|
||||||
props.collapsedFilePaths,
|
props.collapsedFilePaths,
|
||||||
props.displayPreferences.layout,
|
props.displayPreferences.layout,
|
||||||
props.displayPreferences.wrapLines,
|
props.displayPreferences.wrapLines,
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ import {
|
|||||||
} from "./hit-testing";
|
} from "./hit-testing";
|
||||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
import {
|
||||||
|
buildDiffDocumentModel,
|
||||||
|
captureScrollAnchor,
|
||||||
|
FILE_HEADER_HEIGHT,
|
||||||
|
resolveRelayoutScrollTop,
|
||||||
|
} from "./model";
|
||||||
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
||||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||||
import { createMeasuredAdvances } from "./text-measurement";
|
import {
|
||||||
|
createMeasuredAdvances,
|
||||||
|
createAdditiveAdvances,
|
||||||
|
createChunkedWidthMeasurer,
|
||||||
|
requiresShaping,
|
||||||
|
} from "./text-measurement";
|
||||||
import { retainDiffViewport } from "./viewport";
|
import { retainDiffViewport } from "./viewport";
|
||||||
import type {
|
import type {
|
||||||
DiffHit,
|
DiffHit,
|
||||||
@@ -183,6 +193,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||||
},
|
},
|
||||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||||
|
materializationAnchor: previousModelRef.current
|
||||||
|
? captureScrollAnchor(previousModelRef.current, scrollTopRef.current)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
}, [
|
}, [
|
||||||
@@ -1166,5 +1179,13 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
|
|||||||
};
|
};
|
||||||
// Canvas exposes no glyph coverage, so `requiresShaping` alone decides which
|
// Canvas exposes no glyph coverage, so `requiresShaping` alone decides which
|
||||||
// runs a shaper has to see -- there is no `glyphIds` to fall back on.
|
// runs a shaper has to see -- there is no `glyphIds` to fall back on.
|
||||||
return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) };
|
const additive = createAdditiveAdvances(measure);
|
||||||
|
return {
|
||||||
|
measure,
|
||||||
|
measureAdvances: createMeasuredAdvances(measure),
|
||||||
|
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||||
|
const text = graphemes.join("");
|
||||||
|
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface DiffCell {
|
|||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
tokens: DiffTokenRun[];
|
tokens: DiffTokenRun[];
|
||||||
fragments: DiffFragment[];
|
fragments: DiffFragment[];
|
||||||
|
/** Monospace columns per visual line until this cell is measured. */
|
||||||
|
estimatedWrapColumns?: number;
|
||||||
reviewTarget: ReviewableDiffTarget | null;
|
reviewTarget: ReviewableDiffTarget | null;
|
||||||
sourceIdentity: DiffSourceIdentity;
|
sourceIdentity: DiffSourceIdentity;
|
||||||
}
|
}
|
||||||
@@ -185,6 +187,8 @@ export interface BuildDiffDocumentModelInput {
|
|||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
labels: { binary: string; tooLarge: string };
|
labels: { binary: string; tooLarge: string };
|
||||||
materializationWindow?: { top: number; height: number };
|
materializationWindow?: { top: number; height: number };
|
||||||
|
/** Also measure the anchored row so relayout can restore its exact fragment. */
|
||||||
|
materializationAnchor?: DiffScrollAnchor | null;
|
||||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||||
reuseFrom?: readonly DiffDocumentModel[];
|
reuseFrom?: readonly DiffDocumentModel[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,37 @@ describe("diff document workspace cache", () => {
|
|||||||
expect(stats.calls).toBe(0);
|
expect(stats.calls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("materializes a new anchor when the viewport window is unchanged", () => {
|
||||||
|
const cache = createDiffDocumentWorkspaceCache();
|
||||||
|
const source = diffFile();
|
||||||
|
source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({
|
||||||
|
type: "add" as const,
|
||||||
|
content: "x".repeat(200),
|
||||||
|
}));
|
||||||
|
const { measureText } = countingMeasurer();
|
||||||
|
const input = modelInput([source], measureText, {
|
||||||
|
wrapLines: true,
|
||||||
|
materializationWindow: { top: 30, height: 54 },
|
||||||
|
});
|
||||||
|
const first = cache.buildModel(input);
|
||||||
|
const anchored = cache.buildModel({
|
||||||
|
...input,
|
||||||
|
materializationAnchor: {
|
||||||
|
kind: "text",
|
||||||
|
path: source.path,
|
||||||
|
sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" },
|
||||||
|
cellOffset: 90,
|
||||||
|
viewportDelta: -2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const firstRow = first.rows[50]!;
|
||||||
|
const anchoredRow = anchored.rows[50]!;
|
||||||
|
expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0);
|
||||||
|
expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("retains text cells for unchanged files when another file changes", () => {
|
it("retains text cells for unchanged files when another file changes", () => {
|
||||||
const cache = createDiffDocumentWorkspaceCache();
|
const cache = createDiffDocumentWorkspaceCache();
|
||||||
const { measureText } = countingMeasurer();
|
const { measureText } = countingMeasurer();
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): s
|
|||||||
const materializationWindow = input.materializationWindow
|
const materializationWindow = input.materializationWindow
|
||||||
? [input.materializationWindow.top, input.materializationWindow.height]
|
? [input.materializationWindow.top, input.materializationWindow.height]
|
||||||
: null;
|
: null;
|
||||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
|
const anchor = input.materializationAnchor;
|
||||||
|
const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null;
|
||||||
|
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function typographyKey(typography: DiffTypography): string {
|
function typographyKey(typography: DiffTypography): string {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ describe("ensureAgentIsInitialized", () => {
|
|||||||
cursor: { epoch: "epoch-1", seq: 42 },
|
cursor: { epoch: "epoch-1", seq: 42 },
|
||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
|
wholeTurns: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -105,6 +106,7 @@ describe("ensureAgentIsInitialized", () => {
|
|||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
|
wholeTurns: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -148,6 +150,7 @@ describe("ensureAgentIsInitialized", () => {
|
|||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
|
wholeTurns: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -192,6 +195,7 @@ describe("ensureAgentIsInitialized", () => {
|
|||||||
cursor: { epoch: "epoch-1", seq: 12 },
|
cursor: { epoch: "epoch-1", seq: 12 },
|
||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
|
wholeTurns: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -283,6 +287,7 @@ describe("refreshAgent", () => {
|
|||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
|
wholeTurns: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => {
|
|||||||
cursor: { epoch: "epoch-1", seq: 10 },
|
cursor: { epoch: "epoch-1", seq: 10 },
|
||||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
|
wholeTurns: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
type KeyValueStorage,
|
type KeyValueStorage,
|
||||||
type ReleaseChannel,
|
type ReleaseChannel,
|
||||||
type SendBehavior,
|
type SendBehavior,
|
||||||
|
type SendShortcut,
|
||||||
type ServiceUrlBehavior,
|
type ServiceUrlBehavior,
|
||||||
type Settings,
|
type Settings,
|
||||||
type SidebarWorkspaceTrailing,
|
type SidebarWorkspaceTrailing,
|
||||||
@@ -93,6 +94,7 @@ export type {
|
|||||||
KeyValueStorage,
|
KeyValueStorage,
|
||||||
ReleaseChannel,
|
ReleaseChannel,
|
||||||
SendBehavior,
|
SendBehavior,
|
||||||
|
SendShortcut,
|
||||||
ServiceUrlBehavior,
|
ServiceUrlBehavior,
|
||||||
Settings,
|
Settings,
|
||||||
SettingsDeps,
|
SettingsDeps,
|
||||||
|
|||||||
@@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => {
|
|||||||
expect(result.chatOutlineEnabled).toBe(false);
|
expect(result.chatOutlineEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses Enter to send by default", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
|
||||||
|
const result = await loadAppSettingsFromStorage(deps);
|
||||||
|
|
||||||
|
expect(result.sendShortcut).toBe("enter");
|
||||||
|
expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the modifier Enter send preference", async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadAppSettingsFromStorage(deps);
|
||||||
|
|
||||||
|
expect(result.sendShortcut).toBe("mod-enter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Enter when the stored send shortcut is invalid", async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadAppSettingsFromStorage(deps);
|
||||||
|
|
||||||
|
expect(result.sendShortcut).toBe("enter");
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults sidebar navigation items to an empty preference list", async () => {
|
it("defaults sidebar navigation items to an empty preference list", async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations";
|
|||||||
export { APP_SETTINGS_KEY } from "./keys";
|
export { APP_SETTINGS_KEY } from "./keys";
|
||||||
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||||
|
|
||||||
|
export type SendShortcut = "enter" | "mod-enter";
|
||||||
|
|
||||||
export type SendBehavior = ActiveTurnBehavior | "queue";
|
export type SendBehavior = ActiveTurnBehavior | "queue";
|
||||||
export type ReleaseChannel = "stable" | "beta";
|
export type ReleaseChannel = "stable" | "beta";
|
||||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||||
@@ -104,6 +106,7 @@ export interface AppSettings {
|
|||||||
chatDisplay: ChatDisplay;
|
chatDisplay: ChatDisplay;
|
||||||
chatOutlineEnabled: boolean;
|
chatOutlineEnabled: boolean;
|
||||||
vimKeybindings: boolean;
|
vimKeybindings: boolean;
|
||||||
|
sendShortcut: SendShortcut;
|
||||||
filePanelAssociations: Record<string, string>;
|
filePanelAssociations: Record<string, string>;
|
||||||
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
|
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
|
||||||
openInSidePane: OpenInSidePanePreferences;
|
openInSidePane: OpenInSidePanePreferences;
|
||||||
@@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
|||||||
chatDisplay: "collapseTurns",
|
chatDisplay: "collapseTurns",
|
||||||
chatOutlineEnabled: true,
|
chatOutlineEnabled: true,
|
||||||
vimKeybindings: false,
|
vimKeybindings: false,
|
||||||
|
sendShortcut: "enter",
|
||||||
filePanelAssociations: {},
|
filePanelAssociations: {},
|
||||||
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||||||
pullRequestOpenLocation: "explorer",
|
pullRequestOpenLocation: "explorer",
|
||||||
@@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z
|
|||||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||||
chatOutlineEnabled: z.boolean().catch(true),
|
chatOutlineEnabled: z.boolean().catch(true),
|
||||||
vimKeybindings: z.boolean().catch(false),
|
vimKeybindings: z.boolean().catch(false),
|
||||||
|
sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"),
|
||||||
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
|
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
|
||||||
openInSidePane: z
|
openInSidePane: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
|
|||||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||||
|
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2018,12 +2019,13 @@ export const ar: TranslationResources = {
|
|||||||
output: "الإخراج",
|
output: "الإخراج",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "عرض تفاصيل الجولة",
|
||||||
one: "كتب {{count}} رسالة",
|
sentBy: "أرسلها {{name}}",
|
||||||
other: "كتب {{count}} رسائل",
|
showMore: "عرض المزيد",
|
||||||
},
|
showLess: "عرض أقل",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "فكّر",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "حرّر {{count}} ملفًا",
|
one: "حرّر {{count}} ملفًا",
|
||||||
other: "حرّر {{count}} ملفات",
|
other: "حرّر {{count}} ملفات",
|
||||||
@@ -2369,12 +2371,25 @@ export const ar: TranslationResources = {
|
|||||||
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
|
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
|
||||||
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
|
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.",
|
||||||
|
steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.",
|
||||||
|
queue:
|
||||||
|
"عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "مقاطعة",
|
interrupt: "مقاطعة",
|
||||||
steer: "توجيه",
|
steer: "توجيه",
|
||||||
queue: "طابور",
|
queue: "طابور",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "اختصار الإرسال",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "بسأل",
|
ask: "بسأل",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const en = {
|
|||||||
daemonClientUnavailable: "Daemon client unavailable",
|
daemonClientUnavailable: "Daemon client unavailable",
|
||||||
daemonClientDisconnected: "Daemon client is disconnected",
|
daemonClientDisconnected: "Daemon client is disconnected",
|
||||||
noFileFound: "No file found for {{token}}",
|
noFileFound: "No file found for {{token}}",
|
||||||
|
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2043,12 +2044,13 @@ export const en = {
|
|||||||
output: "Output",
|
output: "Output",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "View turn details",
|
||||||
one: "wrote {{count}} message",
|
sentBy: "Sent by {{name}}",
|
||||||
other: "wrote {{count}} messages",
|
showMore: "Show more",
|
||||||
},
|
showLess: "Show less",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "Thought",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "edited {{count}} file",
|
one: "edited {{count}} file",
|
||||||
other: "edited {{count}} files",
|
other: "edited {{count}} files",
|
||||||
@@ -2457,12 +2459,26 @@ export const en = {
|
|||||||
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
|
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
|
||||||
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
|
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt:
|
||||||
|
"When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.",
|
||||||
|
steer:
|
||||||
|
"When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.",
|
||||||
|
queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "Interrupt",
|
interrupt: "Interrupt",
|
||||||
steer: "Steer",
|
steer: "Steer",
|
||||||
queue: "Queue",
|
queue: "Queue",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "Send shortcut",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "Ask",
|
ask: "Ask",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const es: TranslationResources = {
|
|||||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||||
|
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2066,12 +2067,13 @@ export const es: TranslationResources = {
|
|||||||
output: "Producción",
|
output: "Producción",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "Ver detalles del turno",
|
||||||
one: "escribió {{count}} mensaje",
|
sentBy: "Enviado por {{name}}",
|
||||||
other: "escribió {{count}} mensajes",
|
showMore: "Mostrar más",
|
||||||
},
|
showLess: "Mostrar menos",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "Razonó",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "editó {{count}} archivo",
|
one: "editó {{count}} archivo",
|
||||||
other: "editó {{count}} archivos",
|
other: "editó {{count}} archivos",
|
||||||
@@ -2384,12 +2386,27 @@ export const es: TranslationResources = {
|
|||||||
queue:
|
queue:
|
||||||
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
|
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt:
|
||||||
|
"Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.",
|
||||||
|
steer:
|
||||||
|
"Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.",
|
||||||
|
queue:
|
||||||
|
"Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "Interrumpir",
|
interrupt: "Interrumpir",
|
||||||
steer: "Dirigir",
|
steer: "Dirigir",
|
||||||
queue: "Cola",
|
queue: "Cola",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "Atajo de envío",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "Preguntar",
|
ask: "Preguntar",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const fr: TranslationResources = {
|
|||||||
daemonClientUnavailable: "Client du daemon indisponible",
|
daemonClientUnavailable: "Client du daemon indisponible",
|
||||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||||
|
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2065,12 +2066,13 @@ export const fr: TranslationResources = {
|
|||||||
output: "Sortie",
|
output: "Sortie",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "Voir les détails du tour",
|
||||||
one: "{{count}} message écrit",
|
sentBy: "Envoyé par {{name}}",
|
||||||
other: "{{count}} messages écrits",
|
showMore: "Afficher plus",
|
||||||
},
|
showLess: "Afficher moins",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "Réfléchi",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "{{count}} fichier modifié",
|
one: "{{count}} fichier modifié",
|
||||||
other: "{{count}} fichiers modifiés",
|
other: "{{count}} fichiers modifiés",
|
||||||
@@ -2383,12 +2385,27 @@ export const fr: TranslationResources = {
|
|||||||
queue:
|
queue:
|
||||||
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
|
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt:
|
||||||
|
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.",
|
||||||
|
steer:
|
||||||
|
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.",
|
||||||
|
queue:
|
||||||
|
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "Interrompre",
|
interrupt: "Interrompre",
|
||||||
steer: "Réorienter",
|
steer: "Réorienter",
|
||||||
queue: "File d’attente",
|
queue: "File d’attente",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "Raccourci d’envoi",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "Demander",
|
ask: "Demander",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
|
|||||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||||
noFileFound: "{{token}}のファイルが見つかりません",
|
noFileFound: "{{token}}のファイルが見つかりません",
|
||||||
|
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2038,12 +2039,13 @@ export const ja: TranslationResources = {
|
|||||||
output: "出力",
|
output: "出力",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "ターンの詳細を表示",
|
||||||
one: "{{count}}件のメッセージを送信",
|
sentBy: "{{name}} から送信",
|
||||||
other: "{{count}}件のメッセージを送信",
|
showMore: "もっと見る",
|
||||||
},
|
showLess: "折りたたむ",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "思考",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "{{count}}個のファイルを編集",
|
one: "{{count}}個のファイルを編集",
|
||||||
other: "{{count}}個のファイルを編集",
|
other: "{{count}}個のファイルを編集",
|
||||||
@@ -2353,12 +2355,25 @@ export const ja: TranslationResources = {
|
|||||||
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
|
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
|
||||||
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
|
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。",
|
||||||
|
steer:
|
||||||
|
"エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。",
|
||||||
|
queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "中断",
|
interrupt: "中断",
|
||||||
steer: "指示",
|
steer: "指示",
|
||||||
queue: "キュー",
|
queue: "キュー",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "送信ショートカット",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "確認する",
|
ask: "確認する",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
|
|||||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||||
|
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2028,12 +2029,13 @@ export const ko: TranslationResources = {
|
|||||||
output: "출력",
|
output: "출력",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "턴 세부 정보 보기",
|
||||||
one: "{{count}} 메시지를 작성했습니다.",
|
sentBy: "{{name}}이(가) 보냄",
|
||||||
other: "{{count}} 메시지를 작성했습니다.",
|
showMore: "더 보기",
|
||||||
},
|
showLess: "접기",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "생각함",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "{{count}} 파일을 편집했습니다.",
|
one: "{{count}} 파일을 편집했습니다.",
|
||||||
other: "{{count}} 파일을 편집했습니다.",
|
other: "{{count}} 파일을 편집했습니다.",
|
||||||
@@ -2344,12 +2346,27 @@ export const ko: TranslationResources = {
|
|||||||
queue:
|
queue:
|
||||||
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
|
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt:
|
||||||
|
"에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.",
|
||||||
|
steer:
|
||||||
|
"에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.",
|
||||||
|
queue:
|
||||||
|
"에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "중단",
|
interrupt: "중단",
|
||||||
steer: "지시",
|
steer: "지시",
|
||||||
queue: "대기열",
|
queue: "대기열",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "전송 단축키",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "물어보기",
|
ask: "물어보기",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const ptBR: TranslationResources = {
|
|||||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||||
|
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2051,12 +2052,13 @@ export const ptBR: TranslationResources = {
|
|||||||
output: "Saída",
|
output: "Saída",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "Ver detalhes do turno",
|
||||||
one: "escreveu {{count}} mensagem",
|
sentBy: "Enviado por {{name}}",
|
||||||
other: "escreveu {{count}} mensagens",
|
showMore: "Mostrar mais",
|
||||||
},
|
showLess: "Mostrar menos",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "Pensou",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "editou {{count}} arquivo",
|
one: "editou {{count}} arquivo",
|
||||||
other: "editou {{count}} arquivos",
|
other: "editou {{count}} arquivos",
|
||||||
@@ -2367,12 +2369,27 @@ export const ptBR: TranslationResources = {
|
|||||||
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
|
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
|
||||||
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
|
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt:
|
||||||
|
"Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.",
|
||||||
|
steer:
|
||||||
|
"Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.",
|
||||||
|
queue:
|
||||||
|
"Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "Interromper",
|
interrupt: "Interromper",
|
||||||
steer: "Orientar",
|
steer: "Orientar",
|
||||||
queue: "Fila",
|
queue: "Fila",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "Atalho de envio",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "Perguntar",
|
ask: "Perguntar",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
|
|||||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||||
daemonClientDisconnected: "Daemon клиента отключен",
|
daemonClientDisconnected: "Daemon клиента отключен",
|
||||||
noFileFound: "Файл для {{token}} не найден",
|
noFileFound: "Файл для {{token}} не найден",
|
||||||
|
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -2051,12 +2052,13 @@ export const ru: TranslationResources = {
|
|||||||
output: "Выходные данные",
|
output: "Выходные данные",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "Показать подробности хода",
|
||||||
one: "написано {{count}} сообщение",
|
sentBy: "Отправлено агентом {{name}}",
|
||||||
other: "написаны сообщения ({{count}})",
|
showMore: "Показать больше",
|
||||||
},
|
showLess: "Свернуть",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "Обдумал",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "изменён {{count}} файл",
|
one: "изменён {{count}} файл",
|
||||||
other: "изменены файлы ({{count}})",
|
other: "изменены файлы ({{count}})",
|
||||||
@@ -2370,12 +2372,27 @@ export const ru: TranslationResources = {
|
|||||||
queue:
|
queue:
|
||||||
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
|
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt:
|
||||||
|
"Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.",
|
||||||
|
steer:
|
||||||
|
"Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.",
|
||||||
|
queue:
|
||||||
|
"Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "Прерывать",
|
interrupt: "Прерывать",
|
||||||
steer: "Направить",
|
steer: "Направить",
|
||||||
queue: "Поставить в очередь",
|
queue: "Поставить в очередь",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "Сочетание клавиш для отправки",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "Спрашивать",
|
ask: "Спрашивать",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
|
|||||||
daemonClientUnavailable: "Daemon client 不可用",
|
daemonClientUnavailable: "Daemon client 不可用",
|
||||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||||
noFileFound: "未找到 {{token}} 对应的文件",
|
noFileFound: "未找到 {{token}} 对应的文件",
|
||||||
|
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||||
},
|
},
|
||||||
connectionStatus: {
|
connectionStatus: {
|
||||||
@@ -1996,12 +1997,13 @@ export const zhCN: TranslationResources = {
|
|||||||
output: "输出",
|
output: "输出",
|
||||||
},
|
},
|
||||||
turnSummary: {
|
turnSummary: {
|
||||||
messages: {
|
details: "查看轮次详情",
|
||||||
one: "写了 {{count}} 条消息",
|
sentBy: "由 {{name}} 发送",
|
||||||
other: "写了 {{count}} 条消息",
|
showMore: "展开",
|
||||||
},
|
showLess: "收起",
|
||||||
},
|
},
|
||||||
toolCallGroup: {
|
toolCallGroup: {
|
||||||
|
thought: "已思考",
|
||||||
editedFiles: {
|
editedFiles: {
|
||||||
one: "编辑了 {{count}} 个文件",
|
one: "编辑了 {{count}} 个文件",
|
||||||
other: "编辑了 {{count}} 个文件",
|
other: "编辑了 {{count}} 个文件",
|
||||||
@@ -2308,12 +2310,24 @@ export const zhCN: TranslationResources = {
|
|||||||
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
|
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
|
||||||
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
|
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
|
||||||
},
|
},
|
||||||
|
commandEnterDescriptions: {
|
||||||
|
interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。",
|
||||||
|
steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。",
|
||||||
|
queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。",
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
interrupt: "中断",
|
interrupt: "中断",
|
||||||
steer: "引导",
|
steer: "引导",
|
||||||
queue: "排队",
|
queue: "排队",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendShortcut: {
|
||||||
|
label: "发送快捷键",
|
||||||
|
options: {
|
||||||
|
enter: "Enter",
|
||||||
|
"mod-enter": "{{modifier}}+Enter",
|
||||||
|
},
|
||||||
|
},
|
||||||
serviceUrls: {
|
serviceUrls: {
|
||||||
options: {
|
options: {
|
||||||
ask: "询问",
|
ask: "询问",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as FileSystem from "expo-file-system/legacy";
|
||||||
|
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
|
||||||
|
import { createPluginClientBundleStore } from "./store";
|
||||||
|
|
||||||
|
export function createHostPluginClientBundleStore(serverId: string) {
|
||||||
|
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
|
||||||
|
const ensureDirectory = async () => {
|
||||||
|
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
|
||||||
|
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||||
|
};
|
||||||
|
return createPluginClientBundleStore(
|
||||||
|
{
|
||||||
|
async list() {
|
||||||
|
await ensureDirectory();
|
||||||
|
return FileSystem.readDirectoryAsync(directory);
|
||||||
|
},
|
||||||
|
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
|
||||||
|
async writeAtomic(name, contents) {
|
||||||
|
await ensureDirectory();
|
||||||
|
const temporary = `${directory}${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await FileSystem.writeAsStringAsync(temporary, contents);
|
||||||
|
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
|
||||||
|
} finally {
|
||||||
|
await FileSystem.deleteAsync(temporary, { idempotent: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
|
||||||
|
},
|
||||||
|
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createPluginClientBundleStore } from "./store";
|
||||||
|
|
||||||
|
const DATABASE_NAME = "paseo-plugin-client-bundles";
|
||||||
|
const STORE_NAME = "bundles";
|
||||||
|
let opening: Promise<IDBDatabase> | undefined;
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||||
|
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
|
||||||
|
request.addEventListener("success", () => resolve(request.result));
|
||||||
|
request.addEventListener("error", () => reject(request.error));
|
||||||
|
}).catch((error) => {
|
||||||
|
opening = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return opening;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHostPluginClientBundleStore(serverId: string) {
|
||||||
|
const prefix = `${encodeURIComponent(serverId)}/`;
|
||||||
|
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
|
||||||
|
async function run<T>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const database = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORE_NAME, mode);
|
||||||
|
const request = operation(transaction.objectStore(STORE_NAME));
|
||||||
|
transaction.addEventListener("complete", () => resolve(request.result));
|
||||||
|
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||||
|
transaction.addEventListener("error", () => reject(transaction.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createPluginClientBundleStore(
|
||||||
|
{
|
||||||
|
async list() {
|
||||||
|
return (await run("readonly", (store) => store.getAllKeys(range())))
|
||||||
|
.filter((key): key is string => typeof key === "string")
|
||||||
|
.map((key) => key.slice(prefix.length));
|
||||||
|
},
|
||||||
|
async read(name) {
|
||||||
|
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
|
||||||
|
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async writeAtomic(name, contents) {
|
||||||
|
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
|
||||||
|
},
|
||||||
|
async remove(name) {
|
||||||
|
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (contents) => {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { createHostPluginClientBundleStore } from "./index.web";
|
||||||
|
|
||||||
|
async function sha256(contents: string) {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("browser bundles survive a fresh store and remain isolated by host", async () => {
|
||||||
|
const first = createHostPluginClientBundleStore("bundle-test-host-a");
|
||||||
|
const second = createHostPluginClientBundleStore("bundle-test-host-b");
|
||||||
|
const clientBundle = "bundle".repeat(250_000);
|
||||||
|
const entry = {
|
||||||
|
id: "same-plugin",
|
||||||
|
clientBundle,
|
||||||
|
clientBundleHash: await sha256(clientBundle),
|
||||||
|
};
|
||||||
|
const otherCode = "other host code";
|
||||||
|
const other = {
|
||||||
|
id: entry.id,
|
||||||
|
clientBundle: otherCode,
|
||||||
|
clientBundleHash: await sha256(otherCode),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await first.replace([entry]);
|
||||||
|
await second.replace([other]);
|
||||||
|
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
|
||||||
|
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
|
||||||
|
await first.replace([]);
|
||||||
|
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
|
||||||
|
expect(await second.load()).toEqual([other]);
|
||||||
|
} finally {
|
||||||
|
await first.replace([]);
|
||||||
|
await second.replace([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
|
||||||
|
|
||||||
|
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
|
||||||
|
|
||||||
|
class BundleFiles implements PluginBundleFiles {
|
||||||
|
readonly contents = new Map<string, string>();
|
||||||
|
readonly writes: string[] = [];
|
||||||
|
async list() {
|
||||||
|
return [...this.contents.keys()];
|
||||||
|
}
|
||||||
|
async read(name: string) {
|
||||||
|
const contents = this.contents.get(name);
|
||||||
|
if (contents === undefined) throw new Error("File disappeared");
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
async writeAtomic(name: string, contents: string) {
|
||||||
|
this.writes.push(name);
|
||||||
|
this.contents.set(name, contents);
|
||||||
|
}
|
||||||
|
async remove(name: string) {
|
||||||
|
this.contents.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bundle(id: string, clientBundle: string) {
|
||||||
|
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const old = await bundle("changed", "old");
|
||||||
|
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
|
||||||
|
const cold = createPluginClientBundleStore(files, sha256);
|
||||||
|
expect(await cold.load()).toEqual([old, kept]);
|
||||||
|
const changed = await bundle("changed", "new");
|
||||||
|
await cold.replace([kept, changed]);
|
||||||
|
expect(files.contents.size).toBe(2);
|
||||||
|
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
|
||||||
|
expect(files.writes).toHaveLength(3);
|
||||||
|
await cold.replace([]);
|
||||||
|
expect(files.contents.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const entries = await Promise.all([
|
||||||
|
bundle("same-length", "good"),
|
||||||
|
bundle("truncated", "long"),
|
||||||
|
bundle("missing", "lost"),
|
||||||
|
]);
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace(entries);
|
||||||
|
const names = [...files.contents.keys()];
|
||||||
|
files.contents.set(names[0]!, "evil");
|
||||||
|
files.contents.set(names[1]!, "lo");
|
||||||
|
// The directory entry still exists while reading the file fails.
|
||||||
|
const unreadable: PluginBundleFiles = {
|
||||||
|
list: () => files.list(),
|
||||||
|
read: (name) =>
|
||||||
|
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
|
||||||
|
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
|
||||||
|
remove: (name) => files.remove(name),
|
||||||
|
};
|
||||||
|
const cold = createPluginClientBundleStore(unreadable, sha256);
|
||||||
|
expect(await cold.load()).toEqual([]);
|
||||||
|
await cold.replace(entries);
|
||||||
|
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const previous = await bundle("plugin", "previous");
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace([previous]);
|
||||||
|
const failing: PluginBundleFiles = {
|
||||||
|
list: () => files.list(),
|
||||||
|
read: (name) => files.read(name),
|
||||||
|
remove: (name) => files.remove(name),
|
||||||
|
writeAtomic: async () => {
|
||||||
|
throw new Error("Disk full");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
|
||||||
|
).rejects.toThrow("Disk full");
|
||||||
|
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
|
||||||
|
test.each(["valid", "corrupt", "missing"])(
|
||||||
|
"cold catalog uses a %s persisted bundle correctly",
|
||||||
|
async (state) => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const entry = await bundle("plugin", "good code");
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace([entry]);
|
||||||
|
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
|
||||||
|
if (state === "missing") files.contents.clear();
|
||||||
|
let open = () => {};
|
||||||
|
let receive: (data: unknown, binary: boolean) => void = () => {};
|
||||||
|
const requests: Record<string, unknown>[] = [];
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "plugin-cache-test",
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
|
||||||
|
transportFactory: () => ({
|
||||||
|
send(data) {
|
||||||
|
if (typeof data !== "string") return;
|
||||||
|
const frame = JSON.parse(data);
|
||||||
|
if (frame.type !== "session") return;
|
||||||
|
const request = frame.message;
|
||||||
|
if (request.type !== "plugin.catalog.get.request") return;
|
||||||
|
requests.push(request);
|
||||||
|
const { clientBundle: _code, ...reference } = entry;
|
||||||
|
receive(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "plugin.catalog.get.response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
plugins: [request.cachedClientBundles ? reference : entry],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
onOpen(handler) {
|
||||||
|
open = handler;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
onMessage(handler) {
|
||||||
|
receive = handler;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
onClose() {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const connecting = client.connect();
|
||||||
|
open();
|
||||||
|
receive(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "cache-host",
|
||||||
|
hostname: null,
|
||||||
|
version: null,
|
||||||
|
features: { plugins: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await connecting;
|
||||||
|
expect(await client.getPluginCatalog()).toEqual([entry]);
|
||||||
|
if (state === "valid")
|
||||||
|
expect(requests[0]?.cachedClientBundles).toEqual([
|
||||||
|
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
|
||||||
|
]);
|
||||||
|
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
|
||||||
|
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {
|
||||||
|
PluginClientBundle,
|
||||||
|
PluginClientBundleStore,
|
||||||
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
|
|
||||||
|
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
|
||||||
|
export interface PluginBundleFiles {
|
||||||
|
list(): Promise<string[]>;
|
||||||
|
read(name: string): Promise<string>;
|
||||||
|
writeAtomic(name: string, contents: string): Promise<void>;
|
||||||
|
remove(name: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginClientBundleStore(
|
||||||
|
files: PluginBundleFiles,
|
||||||
|
sha256: (contents: string) => Promise<string>,
|
||||||
|
): PluginClientBundleStore {
|
||||||
|
const verified = new Set<string>();
|
||||||
|
const filename = (bundle: PluginClientBundle) =>
|
||||||
|
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
|
||||||
|
return {
|
||||||
|
async load() {
|
||||||
|
const bundles: PluginClientBundle[] = [];
|
||||||
|
for (const name of await files.list()) {
|
||||||
|
try {
|
||||||
|
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
|
||||||
|
if (!match) continue;
|
||||||
|
const id = decodeURIComponent(match[1]!);
|
||||||
|
const clientBundleHash = match[2]!;
|
||||||
|
const clientBundle = await files.read(name);
|
||||||
|
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
|
||||||
|
verified.add(name);
|
||||||
|
bundles.push({ id, clientBundleHash, clientBundle });
|
||||||
|
} catch {
|
||||||
|
// Missing, unreadable or corrupt files are cache misses, never executable code.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bundles;
|
||||||
|
},
|
||||||
|
async replace(bundles) {
|
||||||
|
const retained = new Set<string>();
|
||||||
|
for (const bundle of bundles) {
|
||||||
|
const name = filename(bundle);
|
||||||
|
retained.add(name);
|
||||||
|
if (verified.has(name)) continue;
|
||||||
|
await files.writeAtomic(name, bundle.clientBundle);
|
||||||
|
verified.add(name);
|
||||||
|
}
|
||||||
|
for (const name of await files.list()) {
|
||||||
|
if (retained.has(name)) continue;
|
||||||
|
await files.remove(name);
|
||||||
|
verified.delete(name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||||
import { watchdog } from "@/watchdog";
|
import { watchdog } from "@/watchdog";
|
||||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||||
import type { OwnedSubscription } from "@getpaseo/client";
|
import type { OwnedSubscription } from "@getpaseo/client";
|
||||||
@@ -589,6 +590,7 @@ function createDefaultDeps(
|
|||||||
capabilities: appCapabilities,
|
capabilities: appCapabilities,
|
||||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||||
providerSnapshots: "wire",
|
providerSnapshots: "wire",
|
||||||
|
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||||
...(host.password ? { password: host.password } : {}),
|
...(host.password ? { password: host.password } : {}),
|
||||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||||
} satisfies Omit<DaemonClientConfig, "url">;
|
} satisfies Omit<DaemonClientConfig, "url">;
|
||||||
@@ -668,6 +670,7 @@ function createDefaultDeps(
|
|||||||
),
|
),
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
appVersion: resolveAppVersion() ?? undefined,
|
appVersion: resolveAppVersion() ?? undefined,
|
||||||
|
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -684,6 +687,7 @@ function createDefaultDeps(
|
|||||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||||
capabilities: appCapabilities,
|
capabilities: appCapabilities,
|
||||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||||
|
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getClientId: () => getOrCreateClientId(),
|
getClientId: () => getOrCreateClientId(),
|
||||||
|
|||||||
@@ -306,6 +306,21 @@ async function expectLegacyWorkspaceRefresh({
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ReplicaCache", () => {
|
describe("ReplicaCache", () => {
|
||||||
|
it("persists the complete newest turn beyond the cache item target", async () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const writer = createCache(storage);
|
||||||
|
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||||
|
...timelineItem(`item-${index}`),
|
||||||
|
id: `item-${index}`,
|
||||||
|
turnId: "long-run",
|
||||||
|
}));
|
||||||
|
writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items });
|
||||||
|
await writer.flush();
|
||||||
|
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||||
|
expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id));
|
||||||
|
expect(restored?.range).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
const writer = createCache(storage);
|
const writer = createCache(storage);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user