Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andTodd Miller 56f4af0436 fix(plugins): preserve local work across repeated updates
Retiring only one previous installation delays data loss until the next update. Keep every outgoing installation until explicit removal so its local commits and dirty files remain recoverable.

Co-authored-by: Todd Miller <todd@themillertribe.org>
2026-10-26 11:43:38 +00:00
Todd Miller 9072bce0c3 fix(server): keep the previous plugin installation when an update replaces it
A Git-source install is checked out under
`~/.paseo/plugins/<plugin>/<install-id>/checkout`, and `updateSource` deleted
that directory once the new install was active. Anything in it that was never
pushed went with it: uncommitted files, and commits on a branch or a detached
HEAD, which is how the checkout arrives.

`removeVersion` now retires the outgoing version root as
`<version-root>.old-<timestamp>` instead of removing it. The copy kept by the
previous update is reaped first, so one retired root sits beside the active one
and they do not accumulate. Uninstalling still deletes the whole plugin root,
kept copies included, and a failed preparation or activation still discards its
staging candidate without touching the running installation.

The retired root is logged at info level so the path is discoverable; the update
result keeps its single `warning` field for the case where the old root could
not be kept.

Regression test: "keeps the previous Git installation and reaps it on the next
update" installs from a Git repository, writes into the checkout without
committing, applies a reviewed update, and asserts the retired checkout still
holds that file. It fails on the previous code (`expected [] to have a length of
1`) and passes here. A second update asserts the older copy is reaped and the
newer one is kept.

Refs https://github.com/getpaseo/paseo/issues/7904
2026-10-25 23:42:01 -07:00
428 changed files with 8997 additions and 26480 deletions
-28
View File
@@ -169,34 +169,6 @@ 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
View File
@@ -85,151 +85,6 @@
"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",
-15
View File
@@ -1,20 +1,5 @@
# Changelog # Changelog
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
## 0.13.0 - 2026-10-24 ## 0.13.0 - 2026-10-24
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
-15
View File
@@ -1,20 +1,5 @@
# 更新日志 # 更新日志
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### 修复
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
## 0.13.0 - 2026-10-24 ## 0.13.0 - 2026-10-24
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
+41 -20
View File
@@ -221,6 +221,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
} }
}, },
providers: { providers: {
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string } local: { modelsDir: string }
}, },
agents: { agents: {
@@ -238,8 +244,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
pluginsEnabled: boolean, pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>, plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: { features: {
dictation: { enabled, stt: SpeechSttConfig }, dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig } voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
}, },
log: { log: {
level, format, level, format,
@@ -347,27 +353,42 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload. remains authoritative during reload.
### Speech configuration Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Each use site owns its provider settings: `features.dictation.stt`, Set these to select OpenAI instead of local speech:
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
it regardless of provider. `providers.local.modelsDir` remains shared storage.
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs | Env var | Applies to |
into OpenAI sites that lack their own values, using endpoint settings before shared | ------------------------------ | ------------------------------- |
settings. A provider-less site becomes local; provider names are lower-cased, and | `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
fields ignored by the selected speech provider are discarded. The next save writes only the | `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
inline shape. A legacy key with no site to receive it is discarded with a warning | `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
that names its path, without logging its value.
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
and report effective env sources in `envOverrides`. A site patch that changes ```json
`provider` replaces the entire object. The same or omitted `provider` merges fields {
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and "providers": {
options from the previous variant. "openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
--- ---
+1 -3
View File
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs. Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use. Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`. A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -126,8 +126,6 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows. Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design. The whitespace is the design.
--- ---
-53
View File
@@ -273,26 +273,6 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency `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
@@ -562,39 +542,6 @@ patch every build the consumers use: Metro resolves the `react-native` field of
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module` (`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`,
-6
View File
@@ -94,12 +94,6 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`, `_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to `_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
`.dev/agent-device-artifacts/composer-keyboard-ios`. `.dev/agent-device-artifacts/composer-keyboard-ios`.
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
under an eight-line draft, selection with the keyboard shown and hidden, and
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
The Android sidebar scroll-retention flow requires a connected host with enough The Android sidebar scroll-retention flow requires a connected host with enough
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
+1 -8
View File
@@ -430,15 +430,8 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
Server entries register lifecycle observers with `server.on()` and request transforms with Server 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. `agent.create` edits the agent's saved owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
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 -2
View File
@@ -2,7 +2,7 @@
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart. The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary. In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it. 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. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot. Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Client capability ownership ## Client capability ownership
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

+10 -25
View File
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
The invariants are: 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 complete-turn request, with older history reachable > agent establishes the daemon's current tail in one bounded request, with older history reachable
> through backward pagination. > 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,21 +44,7 @@ recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches the latest available turns with a soft target of 40 Opening or resuming an agent fetches one bounded latest tail page. Older history remains
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.
@@ -163,13 +149,12 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older 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 complete-turn tail The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current. instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail of complete available turns. The item target is soft; range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
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.
@@ -253,11 +238,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions 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.
Every canonical run owns a visible turn, including heartbeats and agent notifications. Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
Notifications delivered during a run stay inside it; batched notifications share the run notifications project into tool rows, so they do not create a human-message boundary. Legacy system
that starts or accepts them. Received messages retain sender attribution, while lifecycle prompts, including schedule injections, remain hidden. One visible response can span several canonical
notifications remain visible events. Collapse, timing and locally mounted history share turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
these boundaries. fork positions retain the canonical `turnId` 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
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-zgyG4roTTTOnNkqOu10gnwwG8GXcjrKH6XlqOvvqKQo= sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
+4140 -2366
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.2", "version": "0.13.0",
"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,7 +131,6 @@
"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",
@@ -155,7 +154,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.3", "react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.5", "react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.4" "react-native-worklets": "0.10.1"
} }
} }
@@ -18,9 +18,7 @@ import {
expectComposerVisible, expectComposerVisible,
cancelAgent, cancelAgent,
composerLocator, composerLocator,
expectQueuedMessageButton,
fillComposerDraft, fillComposerDraft,
holdSendButton,
sendDraftToQueue, sendDraftToQueue,
startRunningMockAgent, startRunningMockAgent,
submitMessage, submitMessage,
@@ -53,7 +51,6 @@ 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 {
@@ -987,7 +984,7 @@ test.describe("Agent message submission", () => {
} }
}); });
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({ test("keeps one prompt when its provider echo crosses the bounded tail", async ({
page, page,
}, testInfo) => { }, testInfo) => {
const workspace = await seedWorkspace({ const workspace = await seedWorkspace({
@@ -1004,10 +1001,8 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page); await expectVisibleAgentSurfacesIdle(page);
await page.reload(); await page.reload();
// The complete turn arrives on reload; scrolling mounts its oldest rows await expect(submittedPrompt).toHaveCount(0);
// without requiring another page or an increase in total content height. await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
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 {
@@ -1295,49 +1290,6 @@ test.describe("Agent message submission", () => {
} }
}); });
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => { test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000); test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude"); await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -42,14 +42,6 @@ const test = base.extend<{ agent: MockAgentSession }>({
}, },
}); });
// The outer meter owns the accessible usage label; its SVG is decorative.
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
const ring = meter.locator("svg");
await expect(ring).toHaveAttribute("aria-hidden", "true");
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
await expect(ring).not.toHaveAttribute("importantForAccessibility");
}
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels. // Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none. // Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> { async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
@@ -108,7 +100,6 @@ test.describe("context window meter", () => {
const meter = page.getByTestId("context-window-meter"); const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 }); await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
await expectDecorativeContextRing(meter);
const centroid = await progressArcCentroid(meter); const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1); expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1); expect(centroid.y).toBeLessThan(-1);
@@ -1,7 +1,6 @@
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";
@@ -297,187 +296,8 @@ 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,
cdp: CDPSession, cdp: CDPSession,
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import { import {
focusTerminalSurface, focusTerminalSurface,
typeInTerminal, typeInTerminal,
@@ -11,11 +12,13 @@ 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 {
openAgentTimeline,
expectTimelinePromptVisible, expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
} from "../support/helpers/timeline-pagination"; } from "../support/helpers/timeline-pagination";
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({ test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
page, page,
}) => { }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
@@ -55,8 +58,22 @@ test("loads history across browser reconnection while retaining old-daemon conne
}; };
try { try {
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates"); for (let index = 0; index < 40; index += 1) {
await agent.client.waitForFinish(agent.agentId, 15_000); await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({ const host = buildSeededHost({
serverId, serverId,
@@ -72,15 +89,34 @@ test("loads history across browser reconnection while retaining old-daemon conne
{ seededHost: host, preferences: buildCreateAgentPreferences() }, { seededHost: host, preferences: buildCreateAgentPreferences() },
); );
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId); await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1); expect(observationSockets.size).toBe(1);
await test.step("reconnect the browser and reload the old daemon history", async () => { await test.step("send a live turn after reconnecting the old daemon", async () => {
observationSockets.clear(); await daemon.restart();
await page.reload(); await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); await expectComposerEditable(page);
await expect.poll(() => observationSockets.size).toBe(1); await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
}); });
await test.step("open a terminal and receive its output", async () => { await test.step("open a terminal and receive its output", async () => {
@@ -94,6 +130,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
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 {
@@ -10,15 +10,6 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { import {
closeCompactSettings, closeCompactSettings,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection, openSettingsSection,
expectSettingsHeader, expectSettingsHeader,
openAddHostFlow, openAddHostFlow,
@@ -63,98 +54,6 @@ async function openWorkspace(
await expect(page.getByTestId("menu-button")).toBeVisible(); await expect(page.getByTestId("menu-button")).toBeVisible();
} }
test.describe("Settings sidebar scroll position", () => {
test.use({ viewport: { width: 1000, height: 500 } });
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await scrollSettingsSidebarToEnd(page);
await page.setViewportSize({ width: 1000, height: 1100 });
await expectSettingsSidebarScrollOffset(page, 0);
await page.setViewportSize({ width: 1000, height: 500 });
await openSettingsSection(page, "appearance");
await expectSettingsSidebarScrollOffset(page, 0);
await scrollSettingsSidebarToEnd(page);
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
await openSettingsHostSection(page, getServerId(), "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
const detailOffset = await scrollSettingsDetailDown(page);
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
await scrollSettingsSidebarUp(page, 60);
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
await expectSettingsDetailScrollOffset(page, detailOffset);
});
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const scrollTop = await scrollSettingsSidebarToEnd(page);
await selectSettingsHostSectionsRapidly(page);
await expectSettingsSidebarScrollOffset(page, scrollTop);
await expectSettingsSidebarScrollSettled(page, scrollTop);
});
test("keeps the sidebar position when switching between app and host sections", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
for (const section of [
"providers",
"terminals",
"providers",
"terminals",
"providers",
] as const) {
await openSettingsHostSection(page, getServerId(), section);
await expect(page.getByTestId("page-title")).toHaveText(
section === "providers" ? "Providers" : "Terminals",
);
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
}
const sidebar = page.getByTestId("settings-sidebar");
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
await openSettingsSection(page, "about");
await expectAboutContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
await openSettingsSection(page, "diagnostics");
await expectDiagnosticsContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
});
});
test.describe("Settings sidebar navigation", () => { test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({ test("opening Settings closes the composer model selector until it is opened again", async ({
page, page,
@@ -1,4 +1,3 @@
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";
@@ -275,123 +274,3 @@ test.describe("Settings toggle tab regression", () => {
} }
}); });
}); });
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
+3 -349
View File
@@ -1,29 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { Page } from "@playwright/test";
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> {
@@ -40,10 +19,7 @@ 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( await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
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;
} }
@@ -60,84 +36,17 @@ 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(/^Edited 1 file/); await expect(summary).toHaveText(/^Wrote \d+ messages?, 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(
@@ -148,9 +57,6 @@ 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);
@@ -159,255 +65,3 @@ 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 attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
} finally {
await recipient.cleanup();
}
});
async function 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(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: /thought/, visible: true })
.last();
await expect(group).toContainText(/read 1 file/i);
await group.getByRole("button").first().click();
await expect(reasoning).toBeVisible();
}
}
async function switchToCollapsedTurns(page: Page) {
await page.evaluate(() => {
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
});
await page.reload();
}
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
page,
}) => {
test.setTimeout(90_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "heartbeat-turn-",
title: "Heartbeat turns",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
const schedules = agent.client as unknown as Pick<
DaemonClient,
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
>;
let scheduleId: string | undefined;
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summaries = page.getByTestId("turn-summary");
await expect(summaries).toHaveCount(1);
const previousSummary = await summaries.first().innerText();
const previousReplyId = await page
.getByTestId("assistant-message")
.last()
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
const created = await schedules.scheduleCreate({
name: "Check the completed work",
prompt: "Inspect the scroll container again",
cadence: { type: "cron", expression: "0 0 1 1 *" },
target: { type: "self", agentId: agent.agentId },
runOnCreate: false,
});
expect(created.error).toBeNull();
scheduleId = created.schedule!.id;
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
expect(fired.error).toBeNull();
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
).toBeVisible();
await expect(summaries.first()).toHaveText(previousSummary);
const previousReply = page
.locator(`[data-message-id="${previousReplyId}"]`)
.getByTestId("assistant-message");
await expect(previousReply).toBeVisible();
await expect(previousReply).toContainText("(end of synthetic stream)");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(summaries).toHaveCount(2);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
} finally {
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
await agent.cleanup();
}
});
@@ -1,93 +0,0 @@
import { expect, test } from "../support/fixtures";
import {
apiKeyInput,
baseUrlInput,
expectSheetClosed,
expectSpeechSiteSummary,
openSpeechSite,
openVoiceSettings,
providerOption,
readSpeechSite,
resetSpeechSite,
saveSpeechSite,
withUnwritableDaemonConfig,
} from "../support/helpers/voice-settings";
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
// download. That makes the provider picker read-only here; the env key covers the key fallback.
const ENV_API_KEY = "sk-e2e-env-fallback";
const SAVED_API_KEY = "sk-e2e-saved-key";
const BASE_URL = "http://127.0.0.1:9/v1";
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
page,
}) => {
try {
await openVoiceSettings(page);
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
const sheet = await test.step("the env var decides the provider", async () => {
const opened = await openSpeechSite(page, "Listening");
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
await expect(providerOption(opened, "Local")).toBeDisabled();
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
return opened;
});
await test.step("a saved key and base URL win over the env key", async () => {
await apiKeyInput(sheet).fill(SAVED_API_KEY);
await baseUrlInput(sheet).fill(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.stt"))
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
});
await test.step("reopening shows the key as saved without revealing it", async () => {
const reopened = await openSpeechSite(page, "Listening");
await expect(apiKeyInput(reopened)).toHaveValue("");
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
});
await test.step("clearing the base URL keeps the saved key", async () => {
const sheetAgain = page.getByRole("dialog");
await baseUrlInput(sheetAgain).fill("");
await saveSpeechSite(sheetAgain);
await expectSheetClosed(page);
const site = await readSpeechSite("voiceMode.stt");
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
expect(site).not.toHaveProperty("baseUrl");
});
} finally {
await resetSpeechSite("voiceMode.stt");
}
});
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
try {
await openVoiceSettings(page);
const sheet = await openSpeechSite(page, "Speaking");
await baseUrlInput(sheet).fill(BASE_URL);
await withUnwritableDaemonConfig(async () => {
await saveSpeechSite(sheet);
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
});
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.tts"))
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
} finally {
await resetSpeechSite("voiceMode.tts");
}
});
@@ -11,10 +11,7 @@ import {
expectWorkspaceTabVisible, expectWorkspaceTabVisible,
openWorkspaceWithAgents, openWorkspaceWithAgents,
} from "../support/helpers/archive-tab"; } from "../support/helpers/archive-tab";
import { import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
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";
@@ -108,83 +105,6 @@ 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,
}) => { }) => {
@@ -28,10 +28,7 @@ function contains(outer, inner) {
* while suggestions are open, so its callers read the composer from `reference`: a * while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input. * snapshot in the same keyboard state with an empty input.
*/ */
export function resolveAutocompletePlacement( export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
snapshot,
{ expected, keyboardTop, reference, anchorGap },
) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot"); if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes; const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect; const popover = findById(nodes, "composer-autocomplete-popover").rect;
@@ -43,10 +40,6 @@ export function resolveAutocompletePlacement(
const keyboardY = const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y; keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover); const popoverBottom = bottomOf(popover);
const gap = composer.y - popoverBottom;
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
}
if (popoverBottom > composer.y + 1) { if (popoverBottom > composer.y + 1) {
throw new Error( throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`, `Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
+25 -136
View File
@@ -14,8 +14,7 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?
const serverId = env("SERVER_ID"); const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID"); const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID"); const workspaceId = env("WORKSPACE_ID");
const scenario = env("SCENARIO", "all"); if (!serverId || !agentId || !workspaceId) {
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
throw new Error( throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)", "Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
); );
@@ -23,12 +22,7 @@ if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
const draftId = env("DRAFT_ID", "composer-keyboard"); const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios"); const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro"); const device = env("DEVICE", "iPhone 16 Pro");
const udid = env("UDID"); const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
const artifactsDir = env(
"ARTIFACTS_DIR",
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
);
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n"; const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after // Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match. // tapping the best match.
@@ -41,7 +35,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true }); fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) { function ad(...args) {
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], { return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8", encoding: "utf8",
maxBuffer: 64 * 1024 * 1024, maxBuffer: 64 * 1024 * 1024,
}); });
@@ -83,7 +77,7 @@ async function settledPlacement(name, keyboard) {
} }
function assertPlacement(name, snap, expected) { function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 }); const placement = resolveAutocompletePlacement(snap, { expected });
process.stdout.write( process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round( `PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height, placement.popover.y + placement.popover.height,
@@ -129,34 +123,21 @@ async function clearInput(name) {
)?.rect; )?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key"); if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length; const length = composerText(keyboard).length;
for (let remaining = length + 2; remaining > 0; remaining -= 100) { ad(
ad( "press",
"press", String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.x + deleteKey.width / 2)), String(Math.round(deleteKey.y + deleteKey.height / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)), "--count",
"--count", String(length + 2),
String(Math.min(remaining, 100)), "--interval-ms",
"--interval-ms", "40",
"40", );
);
}
} }
// The accessibility value drops a trailing space. // The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd(); const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function selectRow(name, row, inserted) { async function runCase(host, kind, { text, expected, inserted }) {
ad("press", String(row.x), String(row.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
);
}
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
const name = `${host}-${kind}`; const name = `${host}-${kind}`;
await clearInput(name); await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`)); await dismissKeyboard(snapshot(`${name}-empty`));
@@ -170,32 +151,31 @@ async function runCase(host, kind, { text, expected, inserted, selectionKeyboard
await clearInput(`${name}-settled`); await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === ""); await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text); ad("type", text);
const opened = assertPlacement( assertPlacement(
`${name} opened after keyboard settled`, `${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true), await settledPlacement(`${name}-open`, true),
expected, expected,
); );
if (selectionKeyboard === "shown") {
await selectRow(name, opened, inserted);
return;
}
await dismissKeyboard(snapshot(`${name}-before-close`)); await dismissKeyboard(snapshot(`${name}-before-close`));
const closed = assertPlacement( assertPlacement(
`${name} keyboard closed`, `${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false), await settledPlacement(`${name}-closed`, false),
expected, expected,
); );
if (selectionKeyboard === "hidden") {
await selectRow(name, closed, inserted);
return;
}
focusAtEnd(snapshot(`${name}-before-reopen`)); focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement( const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`, `${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true), await settledPlacement(`${name}-reopened`, true),
expected, expected,
); );
await selectRow(name, reopened, inserted); ad("press", String(reopened.x), String(reopened.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
);
} }
// Drafts list commands for their selected provider, and a fresh install selects none. // Drafts list commands for their selected provider, and a fresh install selects none.
@@ -236,104 +216,13 @@ async function runHost(host) {
await dismissKeyboard(snapshot(`${host}-done`)); await dismissKeyboard(snapshot(`${host}-done`));
} }
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
async function runLiveGrowth() {
await clearInput("live-growth");
ad("type", "Please do the following now /paseo");
const before = await settledPlacement("live-growth-before", true);
assertPlacement("before growth", before, "/paseo");
const inputHeight = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
ad("type", "--", "-committee");
const grown = await settledPlacement("live-growth-after", true);
if (inputHeight(grown) <= inputHeight(before))
throw new Error("Draft did not wrap onto a new line");
assertPlacement("live growth without reopening", grown, "/paseo-committee");
const deleteKey = grown.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete",
).rect;
ad(
"press",
String(deleteKey.x + deleteKey.width / 2),
String(deleteKey.y + deleteKey.height / 2),
"--count",
"10",
"--interval-ms",
"40",
);
const shrunk = await settledPlacement("live-growth-shrunk", true);
if (inputHeight(shrunk) !== inputHeight(before))
throw new Error("Draft did not shrink back to one line");
assertPlacement("live shrink without reopening", shrunk, "/paseo");
ad("type", "--", "-committ");
const partial = await settledPlacement("live-growth-partial", true);
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
throw new Error("Could not prepare a partial command for selection");
}
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
ad("press", String(row.x), String(row.y));
await waitFor("live-growth-selected", (snap) =>
sameText(composerText(snap), "Please do the following now /paseo-committee "),
);
await clearInput("live-growth-done");
}
async function runBoundedPreview() {
const prefix =
"Please do the following / testing inline selection.\n" +
"Line two with details.\nLine three with details.\nLine four with details.\n" +
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
await runCase("preview", "long", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "shown",
});
await runCase("preview", "long-hidden", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "hidden",
});
await runCase("preview", "longest", {
text: `${prefix}/paseo-plug`,
expected: "/paseo-plugin",
inserted: `${prefix}/paseo-plugin `,
selectionKeyboard: "shown",
});
await runCase("preview", "short", {
text: `${prefix}/com`,
expected: "/commit",
inserted: `${prefix}/commit `,
selectionKeyboard: "shown",
});
await runCase("preview", "files", fileCase);
}
if (scenario !== "all") {
ad(
"open",
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
"--platform",
"ios",
...deviceArgs,
);
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
if (scenario === "growth") await runLiveGrowth();
else if (scenario === "previews") await runBoundedPreview();
else throw new Error(`Unknown scenario: ${scenario}`);
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
process.exit(0);
}
const hosts = [ const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`], ["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`], ["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`], ["new-workspace", `paseo://new?serverId=${serverId}`],
]; ];
for (const [host, url] of hosts) { for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", ...deviceArgs); ad("open", url, "--platform", "ios", "--device", device);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host); await runHost(host);
} }
@@ -87,14 +87,6 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter"); await composerInput(page).press("Control+Enter");
} }
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
export async function holdSendButton(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).hover();
await page.mouse.down();
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
await page.mouse.up();
}
export async function expectQueuedMessageButton(page: Page): Promise<void> { export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({ await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000, timeout: 10_000,
@@ -19,9 +19,6 @@ 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> {
@@ -149,11 +149,7 @@ export interface SeedDaemonClient {
timeout?: number, timeout?: number,
): Promise<{ status: string }>; ): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>; cancelAgent(agentId: string): Promise<void>;
sendAgentMessage( sendAgentMessage(agentId: string, text: string): Promise<void>;
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish( waitForFinish(
agentId: string, agentId: string,
timeout?: number, timeout?: number,
@@ -42,7 +42,6 @@ type HostSection =
| "pair-device" | "pair-device"
| "agents" | "agents"
| "metadata" | "metadata"
| "voice"
| "workspaces" | "workspaces"
| "providers" | "providers"
| "usage" | "usage"
@@ -58,92 +57,6 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
await expectAppRoute(page, buildSettingsSectionRoute(section)); await expectAppRoute(page, buildSettingsSectionRoute(section));
} }
export async function revealSettingsSidebarSection(
page: Page,
section: SettingsSection,
): Promise<number> {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
.scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollSettled(
page: Page,
offset: number,
): Promise<void> {
// RN Web emits its final scroll callback 100 ms after scrolling stops.
await page.waitForTimeout(200);
await expectSettingsSidebarScrollOffset(page, offset);
}
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
for (let selection = 0; selection < 5; selection += 1) {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Providers", exact: true })
.click();
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Terminals", exact: true })
.click();
await expectSettingsHeader(page, "Terminals");
}
}
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
await page.getByTestId("settings-sidebar-scroll-body").hover();
await page.mouse.wheel(0, -pixels);
}
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size" });
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await scrollBody.hover();
await page.mouse.wheel(0, 2000);
await expect
.poll(() =>
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
)
.toBe(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
.toBeGreaterThan(0);
await scrollBody.hover();
await page.mouse.wheel(0, 1000);
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
return offset;
}
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> { export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by // Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host. // clicking the Connections section row; the picker only matters when >1 host.
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
} }
export async function archiveFinishedSubagents(page: Page): Promise<void> { export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click(); await page.getByRole("button", { name: "Archive finished subagents" }).click();
} }
export async function expectArchiveFinishedInProgress( export async function expectArchiveFinishedInProgress(
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
completed: number, completed: number,
total: number, total: number,
): Promise<void> { ): Promise<void> {
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible(); await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
} }
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
total: number, total: number,
): Promise<void> { ): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible(); await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
} }
export async function holdManagedSubagentArchiveRequest( export async function holdManagedSubagentArchiveRequest(
@@ -1,99 +0,0 @@
import { rename, mkdir, rmdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
export async function openVoiceSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "voice");
await expectSettingsHeader(page, "Voice");
}
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
}
export async function expectSpeechSiteSummary(
page: Page,
label: SpeechSiteLabel,
summary: string,
): Promise<void> {
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
}
/** Opens the provider sheet for one use site and returns it. */
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
await speechSiteRow(page, label).click();
const sheet = page.getByRole("dialog");
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
return sheet;
}
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
return sheet.getByRole("button", { name: provider, exact: true });
}
export function apiKeyInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "API key" });
}
export function baseUrlInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "Base URL" });
}
export async function saveSpeechSite(sheet: Locator): Promise<void> {
await sheet.getByRole("button", { name: "Save", exact: true }).click();
}
export async function expectSheetClosed(page: Page): Promise<void> {
await expect(page.getByRole("dialog")).toHaveCount(0);
}
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const { config } = await client.getDaemonConfig();
const features = config.features as Record<string, Record<string, unknown>>;
const [feature, site] = siteId.split(".");
return features[feature][site] as Record<string, unknown>;
} finally {
await client.close().catch(() => undefined);
}
}
/** Puts a speech site back to the default provider so later tests start from a known state. */
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const [feature, site] = siteId.split(".");
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
} finally {
await client.close().catch(() => undefined);
}
}
/** Makes the daemon's config file unreadable for the duration of `run`. */
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
const configPath = path.join(paseoHome, "config.json");
const asidePath = `${configPath}.aside`;
await rename(configPath, asidePath);
await mkdir(configPath);
try {
await run();
} finally {
await rmdir(configPath);
await rename(asidePath, configPath);
}
}
@@ -1,47 +1,12 @@
import { expect, type Page } from "@playwright/test"; import { 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"));
+5 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.2", "version": "0.13.0",
"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.291", "@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "6.1.0-beta.292", "@xterm/xterm": "^6.1.0-beta.213",
"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",
@@ -126,14 +126,14 @@
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12", "react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5", "react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.5", "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0", "react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4", "react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4", "react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "13.16.1", "react-native-webview": "13.16.1",
"react-native-worklets": "0.10.4", "react-native-worklets": "0.10.1",
"tiny-invariant": "^1.3.3", "tiny-invariant": "^1.3.3",
"turndown": "^7.2.4", "turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
@@ -198,14 +198,6 @@
], ],
"enableVideo": false "enableVideo": false
}, },
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"react-native-worklets"
]
}
},
"reanimated": { "reanimated": {
"staticFeatureFlags": { "staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -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: [],
timingByItemId: timingFor(assistant.id), timingByAssistantId: timingFor(assistant.id),
}), }),
}; };
} }
@@ -1,5 +1,4 @@
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;
@@ -33,7 +32,7 @@ export function findMountedWindowStart(input: {
} }
let startIndex = Math.max(items.length - minMountedCount, 0); let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) { while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1; startIndex -= 1;
} }
return startIndex; return startIndex;
+6 -6
View File
@@ -104,7 +104,7 @@ function layoutFor(input: {
strategy, strategy,
streamHead: input.head ?? [], streamHead: input.head ?? [],
}), }),
timingByItemId: timingFor(...(input.timingIds ?? [])), timingByAssistantId: timingFor(...(input.timingIds ?? [])),
}); });
} }
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
} }
describe("layoutStream", () => { describe("layoutStream", () => {
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => { it("places one response footer after an adjacent tagged tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const 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(nextTool.id); expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]); expect(footerOwners(layout)).toEqual([priorAssistant.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,
timingByItemId: timingFor(priorAssistant.id), timingByAssistantId: 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,
timingByItemId: timingFor(priorAssistant.id), timingByAssistantId: timingFor(priorAssistant.id),
}); });
expect(footerOwners(first)).toEqual([]); expect(footerOwners(first)).toEqual([]);
+26 -29
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean; isTurnActive: boolean;
history: StreamItem[]; history: StreamItem[];
liveHead: StreamItem[]; liveHead: StreamItem[];
timingByItemId: Map<string, TurnTiming>; timingByAssistantId: Map<string, TurnTiming>;
} }
interface LayoutSegmentInput { interface LayoutSegmentInput {
strategy: StreamStrategy; strategy: StreamStrategy;
items: StreamItem[]; items: StreamItem[];
timingByItemId: Map<string, TurnTiming>; timingByAssistantId: 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 TurnFooterSource { interface AssistantFooterSource {
item: StreamItem; item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
} }
@@ -67,26 +67,23 @@ function createTurnFooterHost(input: {
item: StreamItem; item: StreamItem;
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
timingByItemId: Map<string, TurnTiming>; timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost { }): TurnFooterHost {
return { return {
itemId: input.item.id, itemId: input.item.id,
items: input.items, items: input.items,
timing: input.timingByItemId.get(input.item.id), timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index, startIndex: input.index,
}; };
} }
function findTurnFooterSource(input: { function findLatestAssistantInResponse(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;
}): TurnFooterSource | null { }): AssistantFooterSource | 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;
@@ -100,7 +97,7 @@ function findTurnFooterSource(input: {
) { ) {
const item = items[index]; const item = items[index];
if (!item || (laterItem && !continuesResponse(item, laterItem))) { if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return toolFallback; return null;
} }
if (item.kind === "assistant_message") { if (item.kind === "assistant_message") {
return { item, items, index }; return { item, items, index };
@@ -114,7 +111,7 @@ function findTurnFooterSource(input: {
input.boundaryAboveIndex === null || input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined input.boundaryAboveIndex === undefined
) { ) {
return toolFallback; return null;
} }
items = input.boundaryAboveItems; items = input.boundaryAboveItems;
@@ -134,20 +131,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null; return null;
} }
const source = findTurnFooterSource({ const assistant = findLatestAssistantInResponse({
strategy: input.strategy, strategy: input.strategy,
items: footerItems, items: footerItems,
startIndex: latestIndex, startIndex: latestIndex,
}); });
if (!source) { if (!assistant) {
return null; return null;
} }
return createTurnFooterHost({ return createTurnFooterHost({
item: source.item, item: assistant.item,
items: source.items, items: assistant.items,
index: source.index, index: assistant.index,
timingByItemId: input.timingByItemId, timingByAssistantId: input.timingByAssistantId,
}); });
} }
@@ -157,7 +154,7 @@ function resolveCompletedFooter(input: {
index: number; index: number;
item: StreamItem; item: StreamItem;
belowItem: StreamItem | null; belowItem: StreamItem | null;
timingByItemId: Map<string, TurnTiming>; timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null; boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null; boundaryAboveIndex: number | null;
@@ -166,21 +163,21 @@ function resolveCompletedFooter(input: {
return null; return null;
} }
const source = findTurnFooterSource({ const assistant = findLatestAssistantInResponse({
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 (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) { if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null; return null;
} }
return createTurnFooterHost({ return createTurnFooterHost({
item: source.item, item: assistant.item,
items: source.items, items: assistant.items,
index: source.index, index: assistant.index,
timingByItemId: input.timingByItemId, timingByAssistantId: input.timingByAssistantId,
}); });
} }
@@ -309,7 +306,7 @@ function layoutSegmentItem(
index, index,
item, item,
belowItem, belowItem,
timingByItemId: input.timingByItemId, timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter, auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems, boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex, boundaryAboveIndex: input.boundaryAboveIndex,
@@ -378,7 +375,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({ history = layoutSegment({
strategy: input.strategy, strategy: input.strategy,
items: input.history, items: input.history,
timingByItemId: input.timingByItemId, timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter, hasAuxiliaryFooter,
frameOrder, frameOrder,
@@ -398,7 +395,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,
timingByItemId: input.timingByItemId, timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter, hasAuxiliaryFooter,
frameOrder, frameOrder,
+9 -9
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2, historyStart: 2,
}); });
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false); expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({ expect(model.turnTiming.byAssistantId.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.byItemId.has("live-a")).toBe(false); expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
}); });
it("maps completed turn timing to assistant ids across committed history and live head", () => { 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.byItemId.get("live-a")).toEqual({ expect(model.turnTiming.byAssistantId.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.byItemId.get("a1")).toEqual({ expect(model.turnTiming.byAssistantId.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.byItemId.size).toBe(0); expect(model.turnTiming.byAssistantId.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.byItemId).toBe(first.turnTiming.byItemId); expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
expect(first.history).toHaveLength(20); expect(first.history).toHaveLength(20);
expect(first.turnTiming.byItemId.has("a29")).toBe(false); expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
expect(first.turnTiming.byItemId.get("a38")).toEqual({ expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
completedAt: tail[77]?.timestamp, completedAt: tail[77]?.timestamp,
durationMs: 1000, durationMs: 1000,
}); });
@@ -588,129 +588,3 @@ 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);
});
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
const call = toolCall("received", "agent_message");
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
call.turnId = "turn";
call.payload.data.agentMessage = {
event: "message",
sender: { id: "sender", title: "Reviewer" },
text: "Ready",
};
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail: [call],
head: [],
});
expect(result.tail[0]).toMatchObject({
kind: "user_message",
id: call.id,
turnId: "turn",
text: "Ready",
sender: { id: "sender", title: "Reviewer" },
});
});
it("does not reopen the previous turn while a new run waits for its first row", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const thought: StreamItem = {
kind: "thought",
id: "thought",
text: "Consider this",
status: "ready",
timestamp,
};
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
for (const item of tail) item.turnId = "run";
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseToolCalls" as const,
isTurnActive: false,
};
const grouped = present({ ...input, autoExpandReasoning: false });
expect(grouped.tail).toHaveLength(1);
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
const separate = present({ ...input, autoExpandReasoning: true });
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
expect(separate.groupsByHostId.has("thought")).toBe(false);
const next = { ...thought, id: "next", turnId: "next-run" };
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
});
it.each([false, true])(
"reveals raw reasoning when opening a turn (Expand thinking %s)",
(autoExpandReasoning) => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns" as const,
isTurnActive: false,
autoExpandReasoning,
};
const present = createStreamPresentation();
const closed = present(input);
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
expect(opened.tail).toEqual(tail);
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
expect(opened.groupsByHostId.size).toBe(0);
},
);
+4 -49
View File
@@ -1,4 +1,3 @@
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";
@@ -17,11 +16,9 @@ interface PresentationInput {
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 {
@@ -58,7 +55,6 @@ 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 collapsedSource: StreamItem[] | undefined; let collapsedSource: StreamItem[] | undefined;
@@ -67,22 +63,6 @@ export function createStreamPresentation() {
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];
} }
@@ -95,14 +75,7 @@ export function createStreamPresentation() {
} }
// 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 = const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
(input.isTurnActive || projection.head.length > 0) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
if ( if (
!collapsed || !collapsed ||
collapsedSource !== projection.tail || collapsedSource !== projection.tail ||
@@ -120,20 +93,7 @@ export function createStreamPresentation() {
collapsedExpandedHostIds = input.expandedTurnHostIds; collapsedExpandedHostIds = input.expandedTurnHostIds;
} }
const { tail, ...turns } = collapsed; const { tail, ...turns } = collapsed;
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns }; 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 => {
@@ -151,15 +111,10 @@ export function createStreamPresentation() {
const blocks = presentRows(item); const blocks = presentRows(item);
head.push(...blocks); head.push(...blocks);
} }
if ( if (preparedTail !== historyRows || preparedLevel !== level) {
preparedTail !== historyRows || preparedHistory = prepareToolCallHistory(level, historyRows);
preparedLevel !== level ||
preparedAutoExpandReasoning !== input.autoExpandReasoning
) {
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
preparedTail = historyRows; preparedTail = historyRows;
preparedLevel = level; preparedLevel = level;
preparedAutoExpandReasoning = input.autoExpandReasoning;
} }
return collapse( return collapse(
projectToolCallDetailLevel({ projectToolCallDetailLevel({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
).toBe("assistant-1\n\nassistant-2"); ).toBe("assistant-1\n\nassistant-2");
}); });
it("keeps copied content within the canonical turn without a visible prompt", () => { it("collects copy content across adjacent turns without a visible prompt", () => {
const chronological: StreamItem[] = [ 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("second turn"); ).toBe("first turn\n\nsecond 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 { reduceStreamUpdate, type StreamItem } from "@/types/stream"; import 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,79 +131,6 @@ 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 });
@@ -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,
timingByItemId: model.turnTiming.byItemId, timingByAssistantId: model.turnTiming.byAssistantId,
}); });
} }
} }
@@ -48,41 +48,6 @@ 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("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"),
@@ -98,6 +63,7 @@ describe("collapseTurns", () => {
hostId: "u1", hostId: "u1",
expanded: false, expanded: false,
summary: { summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }), tools: expect.objectContaining({ commandCount: 2 }),
}, },
}); });
@@ -155,34 +121,6 @@ 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,18 +1,13 @@
import { isTurnBoundary, isTurnTrigger } from "../turn-membership"; import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
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 input or first loaded row that carries the turn's summary line. */ /** The user message the turn answers. Its row carries the turn's summary line. */
hostId: string; hostId: string;
expanded: boolean; expanded: boolean;
summary: TurnSummary; summary: TurnSummary;
@@ -33,7 +28,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 | ThoughtItem)[] | undefined; getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
} }
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map(); const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
@@ -44,21 +39,17 @@ function summarizeTurn(
items: readonly StreamItem[], items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary { ): TurnSummary {
const calls: (ToolCallItem | ThoughtItem)[] = []; const calls: ToolCallItem[] = [];
let messageCount = 0;
for (const item of items) { for (const item of items) {
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item)) if (item.kind === "assistant_message") messageCount += 1;
calls.push(...(getGroupedCalls(item.id) ?? [item])); else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
} }
return { tools: summarizeToolCalls(calls) }; return { messageCount, tools: summarizeToolCalls(calls) };
} }
function hasWork(summary: TurnSummary): boolean { function hasWork(summary: TurnSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
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 {
@@ -72,7 +63,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. Reasoning is revealed only when the turn opens. * before its reply. Thoughts fold with the turn but are not counted.
*/ */
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input; const { tail } = input;
@@ -86,47 +77,28 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
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 ( if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
!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) && !folded.some((item) => item.kind === "assistant_message")) { if (!hasWork(summary)) {
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 });
const revealed = expanded for (const item of folded) {
? 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);
} }
output.push( if (expanded) output.push(...folded);
...(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 ( if (tail[index]?.kind !== "user_message") continue;
!(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;
@@ -142,10 +114,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}; };
} }
return { return {
tail: tail: output.length === tail.length ? tail : output,
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId, turnsByHostId,
nestedItemIds, nestedItemIds,
hostIdByFoldedMessageId, hostIdByFoldedMessageId,
@@ -1,4 +1,4 @@
import { memo, useCallback } from "react"; import { memo, useCallback, useMemo } 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,7 +17,19 @@ 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 label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); const { messageCount, tools } = turn.summary;
const messageParts = useMemo(
() =>
messageCount > 0
? [
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
count: messageCount,
}),
]
: [],
[messageCount, t],
);
const label = useOverviewSummaryLabel(tools, messageParts);
const 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={items[startIndex]?.kind === "assistant_message" ? getContent : undefined} getContent={getContent}
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,
timingByItemId: model.turnTiming.byItemId, timingByAssistantId: model.turnTiming.byAssistantId,
}), }),
}; };
} }
@@ -67,24 +67,6 @@ 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,
@@ -94,7 +76,7 @@ describe("canonical turn membership", () => {
); );
}); });
it("renders a separate footer for each canonical turn without visible prompts", () => { it("renders adjacent turns without visible prompts as one response", () => {
const items = [ const items = [
user("prompt", 1, "turn-1"), user("prompt", 1, "turn-1"),
assistant("first reply", 2, "turn-1"), assistant("first reply", 2, "turn-1"),
@@ -106,11 +88,7 @@ describe("canonical turn membership", () => {
const completed = layoutFor(items, false); const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual([ expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
"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,
@@ -145,7 +123,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.byItemId.get("done")).toMatchObject({ expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
completedAt: at(9), completedAt: at(9),
durationMs: 8000, durationMs: 8000,
}); });
@@ -1,21 +1,24 @@
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
* trigger boundary rule; missing IDs cannot reconstruct historical batching. * user-message boundary rule so old daemons and persisted rows need no rewrite.
*/ */
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean { export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false; if (!previous || !next) return false;
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next)); if (previous.turnId !== undefined && next.turnId !== undefined) {
return previous.turnId === next.turnId;
}
return next.kind !== "user_message";
} }
/** /**
* Every canonical turn owns its response, including system-triggered runs. * A visible response can span multiple canonical turns when their prompts are
* system-injected and therefore absent from the Paseo timeline.
*/ */
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean { export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false; if (!previous || !next) return false;
return continuesTurn(previous, next); return continuesTurn(previous, next) || next.kind !== "user_message";
} }
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
@@ -34,12 +37,3 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean { 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 },
);
}
@@ -1,12 +0,0 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
// The view retains its last visible transcript. Selecting a stable value here
// also prevents incoming chunks from scheduling React work in a hidden panel.
// Revealing the panel reads the current tail directly from the model.
export function useAgentStreamTail(serverId: string, agentId: string | undefined) {
const active = useRetainedPanelActive();
return useSessionStore((state) =>
active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined,
);
}
+12 -49
View File
@@ -90,7 +90,6 @@ 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,
@@ -460,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files", view: "files",
}); });
}); });
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo( const inlinePathOpener = useMemo(
() => () =>
createInlinePathOpener({ createInlinePathOpener({
@@ -468,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client, client,
openFile: openInlineFile, openFile: openInlineFile,
openDirectory: openInlineDirectory, openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}), }),
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory], [workspaceRoot, client, openInlineFile, openInlineDirectory],
); );
useEffect(() => { useEffect(() => {
if (!isActive) inlinePathOpener.cancel(); if (!isActive) inlinePathOpener.cancel();
@@ -527,10 +524,8 @@ 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,
@@ -538,10 +533,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
effectiveStreamHead, effectiveStreamHead,
transformTimelineItem, transformTimelineItem,
chatDisplay, chatDisplay,
autoExpandReasoning,
expandedTurnHostIds, expandedTurnHostIds,
isTurnActive, isTurnActive,
effectiveTurnPresentation.turnId,
], ],
); );
const { const {
@@ -583,12 +576,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive, isTurnActive,
history: baseRenderModel.history, history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead, liveHead: baseRenderModel.segments.liveHead,
timingByItemId: baseRenderModel.turnTiming.byItemId, timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}), }),
[ [
baseRenderModel.history, baseRenderModel.history,
baseRenderModel.segments.liveHead, baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byItemId, baseRenderModel.turnTiming.byAssistantId,
isTurnActive, isTurnActive,
streamRenderStrategy, streamRenderStrategy,
], ],
@@ -729,7 +722,6 @@ 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()}
@@ -876,15 +868,10 @@ 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 item.kind === "thought" return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
} }
const expanded = expandedToolCallGroupIds.has(group.run.id); const expanded = expandedToolCallGroupIds.has(group.run.id);
return ( return (
@@ -897,21 +884,10 @@ 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}>
{call.kind === "thought" ? ( {renderSingleToolCallItem(
<ThoughtSlot call,
itemId={call.id} index === group.run.calls.length - 1,
text={call.text} GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)
)} )}
</React.Fragment> </React.Fragment>
)) ))
@@ -921,8 +897,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, },
[ [
expandedToolCallGroupIds, expandedToolCallGroupIds,
renderThoughtItem,
setInlineDetailsExpanded,
getToolCallGroup, getToolCallGroup,
renderSingleToolCallItem, renderSingleToolCallItem,
setToolCallGroupExpanded, setToolCallGroupExpanded,
@@ -940,20 +914,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderAssistantMessageItem(layoutItem, item); return renderAssistantMessageItem(layoutItem, item);
case "thought": case "thought":
return renderToolCallItem(layoutItem, item); return renderThoughtItem(layoutItem, item);
case "tool_call": case "tool_call":
return ( return renderToolCallItem(layoutItem, item);
<>
{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} />;
@@ -983,9 +947,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId, agentId,
renderUserMessageItem, renderUserMessageItem,
renderAssistantMessageItem, renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem, renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId, resolvedServerId,
], ],
); );
@@ -70,17 +70,6 @@ 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)];
+1 -1
View File
@@ -72,7 +72,7 @@ import {
type StartupBlocker, type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap"; } from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { useSettingsRouteEntry } from "@/navigation/settings-presentation"; import { useSettingsRouteEntry } from "@/navigation/settings-selection";
import { ThemedStack } from "@/navigation/themed-stack"; import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation"; import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -5,7 +5,6 @@ import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks"; import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
@@ -88,7 +87,11 @@ function HostAgentReadyRouteContent() {
]); ]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated(); const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useDirectoryDemand(serverId); useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({ const resolution = resolveAgentRoute({
hasHydratedWorkspaces, hasHydratedWorkspaces,
hasHydratedLayout, hasHydratedLayout,
@@ -9,7 +9,6 @@ import {
useActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -111,10 +110,6 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: ""; : "";
const openValue = getParamValue(params.open); const openValue = getParamValue(params.open);
// A restored workspace with no open chat has nothing else demanding its host's directory on a
// phone, where the sidebar only demands while its panel is active. Without demand the deck
// waits on workspace hydration that never starts.
useDirectoryDemand(serverId);
useEffect(() => { useEffect(() => {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return; return;
@@ -71,9 +71,6 @@ 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");
@@ -98,9 +95,6 @@ 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();
@@ -108,25 +102,3 @@ 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,23 +14,12 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null; client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
} }
type PathOpenTarget = type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
| { 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 isOutsideWorkspacePath(input.directory) return { kind: "directory", directoryPath: input.directory };
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
} }
if ( if (
input.lineStart !== undefined || input.lineStart !== undefined ||
@@ -53,7 +42,6 @@ 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 {
@@ -70,8 +58,6 @@ 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,
@@ -112,9 +112,8 @@ const ContextWindowRing = withUnistyles(function ContextWindowRing({
width={size} width={size}
height={size} height={size}
viewBox={`0 0 ${size} ${size}`} viewBox={`0 0 ${size} ${size}`}
{...(isNative accessibilityElementsHidden
? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" } importantForAccessibility="no-hide-descendants"
: { "aria-hidden": true })}
> >
<Circle <Circle
cx={center} cx={center}
@@ -77,7 +77,6 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory"; import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory";
const SORT_OPTIONS: { value: SortOption }[] = [ const SORT_OPTIONS: { value: SortOption }[] = [
@@ -451,15 +450,9 @@ export function FileExplorerPane({
isLocalExecution: isLocalDaemon, isLocalExecution: isLocalDaemon,
}); });
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
const projectId = useWorkspaceFields(
serverId,
workspaceId ?? null,
(workspace) => workspace.projectId,
);
const openDirectoryInEditor = useOpenDirectoryInEditor({ const openDirectoryInEditor = useOpenDirectoryInEditor({
serverId, serverId,
workspaceDirectory: normalizedWorkspaceRoot, workspaceDirectory: normalizedWorkspaceRoot,
projectId,
}); });
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
const fsEntryOpsEnabled = useSessionStore( const fsEntryOpsEnabled = useSessionStore(
+4 -3
View File
@@ -3,10 +3,11 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
/** /**
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so * Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
* expanded tool cards stay in place, with a gap between their border and the rail. * column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
* hangs from that center so opened rows read as children of the line that opened them.
*/ */
const RAIL_LEFT = 1; const RAIL_LEFT = 7;
const RAIL_WIDTH = 1; const RAIL_WIDTH = 1;
export const INDENT_RAIL_CONTENT_OFFSET = 21; export const INDENT_RAIL_CONTENT_OFFSET = 21;
+40 -48
View File
@@ -1,4 +1,3 @@
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";
@@ -90,7 +89,6 @@ 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;
@@ -389,7 +387,6 @@ 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,
@@ -476,45 +473,42 @@ 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) => ( <UserMessageImagePill
<UserMessageImagePill key={image.id}
key={image.id} image={image}
image={image} onOpen={setLightboxMetadata}
onOpen={setLightboxMetadata} accessibilityLabel={t("composer.attachments.openImage")}
accessibilityLabel={t("composer.attachments.openImage")} />
/> ))}
))} </View>
</View> ) : null}
) : null} {hasAttachments ? (
{hasAttachments ? ( <View style={attachmentPreviewContainerStyle}>
<View style={attachmentPreviewContainerStyle}> {attachments.map((attachment, index) => {
{attachments.map((attachment, index) => { const content = getAgentAttachmentPillContent(attachment, t);
const content = getAgentAttachmentPillContent(attachment, t); return (
return ( <AttachmentFrame
<AttachmentFrame key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`} >
> <AttachmentLabel
<AttachmentLabel icon={content.icon}
icon={content.icon} title={content.title}
title={content.title} subtitle={content.subtitle}
subtitle={content.subtitle} />
/> </AttachmentFrame>
</AttachmentFrame> );
); })}
})} </View>
</View> ) : null}
) : null} {hasText ? (
{hasText ? ( <Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}> {message}
{message} </Text>
</Text> ) : null}
) : null}
</MessagePreview>
</View> </View>
{hasText ? ( {hasText ? (
<View <View
@@ -547,7 +541,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;
@@ -647,12 +641,10 @@ 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
@@ -1,4 +1,3 @@
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";
@@ -43,7 +42,8 @@ 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, {
...planTimelineTailFetch(), direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
}); });
} }
@@ -1,18 +1,20 @@
import { ChevronRight } from "lucide-react-native";
import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
import { View } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { NestedSettingsRows } from "./row-density"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { SettingsRowHeader } from "./row-header";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
interface SettingsCollapsibleRowProps { interface SettingsCollapsibleRowProps {
label: string; label: string;
/** A short status shown before the chevron, so the row says something while collapsed. */
summary?: string;
defaultExpanded?: boolean; defaultExpanded?: boolean;
testID?: string; testID?: string;
/** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */ /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
children: ReactNode; children: ReactNode;
} }
@@ -23,7 +25,6 @@ interface SettingsCollapsibleRowProps {
*/ */
export function SettingsCollapsibleRow({ export function SettingsCollapsibleRow({
label, label,
summary,
defaultExpanded = false, defaultExpanded = false,
testID, testID,
children, children,
@@ -36,39 +37,66 @@ export function SettingsCollapsibleRow({
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null), () => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded], [expanded],
); );
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
hovered && styles.headerHovered,
pressed && styles.headerPressed,
],
[],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return ( return (
<View testID={testID}> <View testID={testID}>
<SettingsRowHeader <Pressable
label={label} {...webExpandedProps}
summary={summary} accessibilityRole="button"
onPress={toggle} accessibilityLabel={label}
expanded={expanded}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
webAccessibilityProps={webExpandedProps} onPress={toggle}
style={headerStyle}
testID={testID ? `${testID}-toggle` : undefined} testID={testID ? `${testID}-toggle` : undefined}
/> >
{expanded ? ( {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<View style={styles.children}> <>
<NestedSettingsRows> <View style={settingsStyles.rowContent}>
{Children.toArray(children).map((child, index) => ( <Text style={settingsStyles.rowTitle}>{label}</Text>
<View </View>
key={isValidElement(child) ? child.key : index} <View style={chevronStyle}>
style={settingsStyles.rowBorder} <ThemedChevron
> size={ICON_SIZE.sm}
{child} uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
</View> />
))} </View>
</NestedSettingsRows> </>
</View> )}
) : null} </Pressable>
{expanded
? Children.toArray(children).map((child, index) => (
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
{child}
</View>
))
: null}
</View> </View>
); );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
// The revealed rows keep the card's rails; the tint is what groups them under their header. headerHovered: {
children: { backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0, },
headerPressed: {
backgroundColor: theme.colors.surface3,
},
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
chevron: { marginRight: -theme.spacing[1] },
chevronExpanded: {
transform: [{ rotate: "90deg" }],
}, },
})); }));
@@ -1,33 +0,0 @@
import { SettingsRowHeader } from "./row-header";
interface SettingsDetailRowProps {
label: string;
/** What the detail is set to, so the row says it without being opened. */
summary?: string;
onPress: () => void;
/** A row that cannot be opened keeps its summary and drops the chevron. */
disabled?: boolean;
testID?: string;
}
/**
* A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line
* summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`.
*/
export function SettingsDetailRow({
label,
summary,
onPress,
disabled,
testID,
}: SettingsDetailRowProps) {
return (
<SettingsRowHeader
label={label}
summary={summary}
onPress={onPress}
disabled={disabled}
testID={testID}
/>
);
}
@@ -1,4 +1,4 @@
import type { ComponentType, ReactNode } from "react"; import type { ReactNode } from "react";
import { Pressable, Text } from "react-native"; import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Info } from "lucide-react-native"; import { Info } from "lucide-react-native";
@@ -6,13 +6,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
type TipIconComponent = ComponentType<{ size: number; color: string }>; const ThemedInfo = withUnistyles(Info);
function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedTipIcon = withUnistyles(TipIcon);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -20,37 +14,27 @@ interface SettingsInfoTipProps {
/** Header the tip belongs to; used for the accessibility label. */ /** Header the tip belongs to; used for the accessibility label. */
title: string; title: string;
info: ReactNode; info: ReactNode;
/** Defaults to the info icon. */
icon?: TipIconComponent;
/** Defaults to "About {title}". */
accessibilityLabel?: string;
testID?: string; testID?: string;
} }
/** /**
* The one way a settings group or section explains itself: an info icon on the * The one way a settings group or section explains itself: an info icon on the
* header that opens a tooltip. Never a paragraph under the header — see * header that opens a tooltip. Never a paragraph under the header — see
* docs/design.md §7. Rows use it the same way to keep secondary details on one line. * docs/design.md §7.
*/ */
export function SettingsInfoTip({ export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
title,
info,
icon = Info,
accessibilityLabel,
testID,
}: SettingsInfoTipProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? t("settings.groupInfo", { title })} accessibilityLabel={t("settings.groupInfo", { title })}
testID={testID} testID={testID}
hitSlop={8} hitSlop={8}
style={styles.button} style={styles.button}
> >
<ThemedTipIcon icon={icon} uniProps={mutedIconMapping} /> <ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="start" offset={8}> <TooltipContent side="top" align="start" offset={8}>
+11 -25
View File
@@ -24,21 +24,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component
import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { useSettingsRowLayout } from "./row-density";
export { SettingsGroup } from "./headings/settings-group"; export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section"; export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row"; export { SettingsCollapsibleRow } from "./collapsible-row";
export { SettingsDetailRow } from "./detail-row";
export { SettingsInfoTip } from "./headings/settings-info-tip";
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */ interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
interface AppRowExtras {
labelAccessory?: ReactNode;
}
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint">, AppRowExtras {
hint?: ReactNode; hint?: ReactNode;
labelAccessory?: ReactNode;
} }
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
@@ -65,32 +57,26 @@ export function SettingsRow({
testID, testID,
}: AppSettingsRowProps) { }: AppSettingsRowProps) {
const compact = useIsCompactFormFactor(); const compact = useIsCompactFormFactor();
const layout = useSettingsRowLayout(); const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]);
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
return ( return (
<View style={rowStyle} testID={testID}> <View style={rowStyle} testID={testID}>
<View style={styles.label}> <View style={styles.label}>
{labelAccessory ? ( {labelAccessory ? (
<View style={styles.labelRow}> <View style={styles.labelRow}>
<Text style={accessoryTitleStyle}>{label}</Text> <Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
{labelAccessory} {labelAccessory}
</View> </View>
) : ( ) : (
<Text style={layout.title}>{label}</Text> <Text style={settingsStyles.rowTitle}>{label}</Text>
)} )}
{typeof hint === "string" ? <Text style={layout.hint}>{hint}</Text> : hint} {typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
{error ? ( {error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}> <Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error} {error}
</Text> </Text>
) : null} ) : null}
</View> </View>
{children ? ( {children ? <View style={styles.control}>{children}</View> : null}
<View style={styles.control}>
<ControlSizeProvider size={layout.controlSize}>{children}</ControlSizeProvider>
</View>
) : null}
</View> </View>
); );
} }
@@ -131,7 +117,7 @@ export function SettingsSelect<Value extends string>({
onValueChange, onValueChange,
disabled, disabled,
...row ...row
}: SettingsSelectProps<Value> & AppRowExtras) { }: SettingsSelectProps<Value>) {
const selectedLabel = options.find((option) => option.value === value)?.label ?? value; const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
return ( return (
<SettingsRow {...row}> <SettingsRow {...row}>
@@ -166,7 +152,7 @@ export function SettingsInput({
secureTextEntry, secureTextEntry,
ref, ref,
...row ...row
}: SettingsInputProps & AppRowExtras) { }: SettingsInputProps) {
const compact = useIsCompactFormFactor(); const compact = useIsCompactFormFactor();
const input = useRef<EditingTextInputHandle>(null); const input = useRef<EditingTextInputHandle>(null);
useImperativeHandle( useImperativeHandle(
@@ -189,7 +175,7 @@ export function SettingsInput({
editable={!disabled} editable={!disabled}
secureTextEntry={secureTextEntry} secureTextEntry={secureTextEntry}
accessibilityLabel={row.label} accessibilityLabel={row.label}
size={compact ? "md" : undefined} size={compact ? "md" : "sm"}
style={styles.input} style={styles.input}
/> />
</SettingsRow> </SettingsRow>
@@ -199,7 +185,7 @@ export function SettingsInput({
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
return ( return (
<SettingsRow {...row}> <SettingsRow {...row}>
<Button variant="outline" onPress={onPress} disabled={disabled}> <Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
{actionLabel} {actionLabel}
</Button> </Button>
</SettingsRow> </SettingsRow>
@@ -1,77 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Button } from "@/components/ui/button";
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function mountRows() {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(
<SettingsCard>
<SettingsRow label="Card row" testID="card-row">
<Button variant="outline" testID="card-button">
Download
</Button>
</SettingsRow>
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
<SettingsRow label="Nested with a button" testID="nested-with-button">
<Button variant="outline" testID="nested-button">
Download
</Button>
</SettingsRow>
<SettingsRow label="Nested without a button" testID="nested-without-button" />
</SettingsCollapsibleRow>
</SettingsCard>,
),
);
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found;
}
function heightOf(testID: string): number {
return element(testID).getBoundingClientRect().height;
}
function titleFontSize(rowTestID: string, label: string): number {
const title = [...element(rowTestID).querySelectorAll("div")].find(
(node) => node.textContent === label && node.children.length === 0,
);
if (!title) throw new Error(`No title ${label}`);
return Number.parseFloat(getComputedStyle(title).fontSize);
}
describe("settings row density", () => {
it("lines up nested rows with and without a button, tighter than card rows", () => {
mountRows();
expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button"));
expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row"));
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
});
it("sizes nested text and controls one step below card rows", () => {
mountRows();
expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button"));
expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan(
titleFontSize("card-row", "Card row"),
);
});
});
@@ -1,57 +0,0 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import type { ContainedControlSize } from "@/components/ui/control-size";
import { settingsStyles } from "@/styles/settings";
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
// never knows on its own that it is nested.
const NestedRowsContext = createContext(false);
export function NestedSettingsRows({ children }: { children: ReactNode }) {
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
}
export interface SettingsRowLayout {
row: StyleProp<ViewStyle>;
title: StyleProp<TextStyle>;
hint: StyleProp<TextStyle>;
/** The size of the controls in the row's trailing slot. */
controlSize: ContainedControlSize;
}
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
export function useSettingsRowLayout(): SettingsRowLayout {
const nested = useContext(NestedRowsContext);
return useMemo(
() =>
nested
? {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "xs",
}
: {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "sm",
},
[nested],
);
}
const styles = StyleSheet.create((theme) => ({
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
},
nestedText: {
fontSize: theme.fontSize.sm,
},
}));
@@ -1,113 +0,0 @@
import { ChevronRight } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { PressHighlight } from "@/components/ui/press-highlight";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useSettingsRowLayout } from "./row-density";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
type HoverState = PressableStateCallbackType & { hovered?: boolean };
export interface SettingsRowHeaderProps {
label: string;
/** A short status shown before the chevron. */
summary?: string;
onPress: () => void;
disabled?: boolean;
/** Turns the chevron down, for a row whose children are revealed in place. */
expanded?: boolean;
accessibilityState?: AccessibilityState;
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
webAccessibilityProps?: Record<string, unknown> | null;
testID?: string;
}
/**
* The pressable line shared by settings rows that lead somewhere: a label, an optional summary and
* a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it
* pointing right.
*/
export function SettingsRowHeader({
label,
summary,
onPress,
disabled,
expanded = false,
accessibilityState,
webAccessibilityProps,
testID,
}: SettingsRowHeaderProps) {
const layout = useSettingsRowLayout();
const rowStyle = useCallback(
({ hovered, pressed }: HoverState) => [
layout.row,
!disabled && hovered && styles.hovered,
!disabled && pressed && styles.pressed,
],
[disabled, layout],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<PressHighlight
{...webAccessibilityProps}
accessibilityRole="button"
accessibilityLabel={summary ? `${label}, ${summary}` : label}
accessibilityState={accessibilityState}
disabled={disabled}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.pressed}
testID={testID}
>
{({ hovered }: HoverState) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={layout.title}>{label}</Text>
</View>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
)}
</>
)}
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
summary: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginRight: theme.spacing[2],
},
hovered: {
backgroundColor: theme.colors.surface2,
},
pressed: {
backgroundColor: theme.colors.surface3,
},
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
chevron: { marginRight: -theme.spacing[1] },
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
}));
@@ -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, densityIconProps } from "@/styles/density"; import { density, densityIconBox } 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 uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} /> <ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
); );
} }
@@ -2705,12 +2705,8 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6, marginRight: -6,
}, },
projectKebabButton: { projectKebabButton: {
// Larger on compact so the kebab is an easy tap target. The negative margins keep its width: 24,
// dots on the same rail as the 24px desktop box and the row at its 44px height. height: 24,
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,7 +17,6 @@ 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 {
@@ -71,11 +70,13 @@ 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 <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />; return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
} }
export interface SidebarWorkspaceMenuProps { export interface SidebarWorkspaceMenuProps {
@@ -438,18 +439,12 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
trigger: { trigger: {
// Larger on compact so the kebab is an easy tap target. padding: 2,
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: { xs: -14, md: -7 }, marginRight: -7,
}, },
triggerHovered: { triggerHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -5,9 +5,9 @@ import {
useRef, useRef,
useState, useState,
type ReactElement, type ReactElement,
type ReactNode, type RefObject,
} from "react"; } from "react";
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native"; import { View, useWindowDimensions } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets"; import { scheduleOnRN } from "react-native-worklets";
@@ -32,8 +32,7 @@ interface RelativeAnchorRect {
interface AutocompletePopoverProps { interface AutocompletePopoverProps {
visible: boolean; visible: boolean;
children: ReactNode; anchorRef: RefObject<View | null>;
anchorStyle?: StyleProp<ViewStyle>;
options: readonly AutocompleteOption[]; options: readonly AutocompleteOption[];
selectedIndex: number; selectedIndex: number;
onSelect: (option: AutocompleteOption) => void; onSelect: (option: AutocompleteOption) => void;
@@ -45,8 +44,7 @@ interface AutocompletePopoverProps {
export function AutocompletePopover({ export function AutocompletePopover({
visible, visible,
children, anchorRef,
anchorStyle,
options, options,
selectedIndex, selectedIndex,
onSelect, onSelect,
@@ -57,7 +55,6 @@ export function AutocompletePopover({
}: AutocompletePopoverProps): ReactElement | null { }: AutocompletePopoverProps): ReactElement | null {
"use no memo"; "use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render. // React Compiler memoizes effect captures by reading SharedValue.value during render.
const anchorRef = useRef<View>(null);
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const windowDimensions = useWindowDimensions(); const windowDimensions = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets(); const safeAreaInsets = useSafeAreaInsets();
@@ -140,29 +137,25 @@ export function AutocompletePopover({
[anchorY, reservedLift, safeAreaInsets.top], [anchorY, reservedLift, safeAreaInsets.top],
); );
const showPopover = canMeasure && relativeAnchorRect && baseStyle; if (!visible || !relativeAnchorRect || !baseStyle) return null;
if (options.length > 0 && selectedIndex < 0) return null;
// The translate view is the popover's own frame, so it lifts only the popover. // The translate view is the popover's own frame, so it lifts only the popover.
return ( return (
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}> <Portal hostName={portalHostName}>
{children} <KeyboardTranslateView {...anchor.translation} style={baseStyle}>
{showPopover ? ( <Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<Portal hostName={portalHostName}> <Autocomplete
<KeyboardTranslateView {...anchor.translation} style={baseStyle}> options={options}
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}> selectedIndex={selectedIndex}
<Autocomplete onSelect={onSelect}
options={options} isLoading={isLoading}
selectedIndex={selectedIndex} errorMessage={errorMessage}
onSelect={onSelect} loadingText={loadingText}
isLoading={isLoading} emptyText={emptyText}
errorMessage={errorMessage} />
loadingText={loadingText} </Animated.View>
emptyText={emptyText} </KeyboardTranslateView>
/> </Portal>
</Animated.View>
</KeyboardTranslateView>
</Portal>
) : null}
</View>
); );
} }
@@ -247,16 +247,14 @@ export function Autocomplete({
<View style={styles.outerWrapper}> <View style={styles.outerWrapper}>
{selectedOption?.kind === "command" && selectedOption.description ? ( {selectedOption?.kind === "command" && selectedOption.description ? (
<View style={styles.detailCard}> <View style={styles.detailCard}>
<Text style={styles.detailLabel} numberOfLines={1}> <Text style={styles.detailLabel}>
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
</Text> </Text>
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail"> <Text style={styles.detailDescription}>
{removeBoltGlyphs(selectedOption.description)} {removeBoltGlyphs(selectedOption.description)}
</Text> </Text>
{selectedOption.detail ? ( {selectedOption.detail ? (
<Text style={styles.detailHint} numberOfLines={1}> <Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
{removeBoltGlyphs(selectedOption.detail)}
</Text>
) : null} ) : null}
</View> </View>
) : null} ) : null}
@@ -295,9 +293,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
detailCard: { detailCard: {
flexShrink: 1,
minHeight: 0,
overflow: "hidden",
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
@@ -323,8 +318,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
}, },
container: { container: {
flexShrink: 1, flexShrink: 1,
// Keep a complete selectable row (36), list padding (8), and borders (2). minHeight: 0,
minHeight: 46,
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
+1 -4
View File
@@ -1,5 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useContainedControlSize } from "@/components/ui/control-size";
import { import {
default as React, default as React,
useCallback, useCallback,
@@ -163,7 +162,7 @@ const styles = StyleSheet.create((theme) => {
export function Button({ export function Button({
children, children,
variant = "secondary", variant = "secondary",
size: sizeProp, size = "md",
leftIcon, leftIcon,
trailing, trailing,
style, style,
@@ -186,8 +185,6 @@ export function Button({
} }
>) { >) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const isDisabled = disabled || loading; const isDisabled = disabled || loading;
let variantStyle: ViewStyle; let variantStyle: ViewStyle;
@@ -81,8 +81,6 @@ describe("control geometry", () => {
it("keeps field text sizing tied to control size", () => { it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme); const geometry = createControlGeometry(theme);
expect(geometry.fieldTextXs.fontSize).toBe(12);
expect(geometry.fieldTextXs.lineHeight).toBe(17);
expect(geometry.fieldTextSm.fontSize).toBe(14); expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20); expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextMd.fontSize).toBe(14); expect(geometry.fieldTextMd.fontSize).toBe(14);
@@ -97,13 +95,6 @@ describe("control geometry", () => {
const geometry = createControlGeometry(theme); const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1]; const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlXs.minHeight).toBe(28);
expect(
geometry.fieldTextXs.lineHeight +
geometry.fieldControlXs.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlXs.minHeight);
expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(5); expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect( expect(
@@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "xs" | "sm" | "md"; export type FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md"; export type SegmentedControlSize = "xs" | "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active"; export type ControlInteractionPhase = "rest" | "hover" | "active";
@@ -188,19 +188,8 @@ export function resolveControlInteractionStyles(
export function createControlGeometry(theme: Theme) { export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1]; const controlBorderWidth = theme.borderWidth[1];
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlXs = {
minHeight: CONTROL_HEIGHTS.tight,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.tight,
fieldTextXsLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlSm = { const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact, minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -221,10 +210,6 @@ export function createControlGeometry(theme: Theme) {
), ),
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}; };
const fieldTextXs = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextXsLineHeight,
};
const fieldTextSm = { const fieldTextSm = {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
lineHeight: fieldTextSmLineHeight, lineHeight: fieldTextSmLineHeight,
@@ -267,10 +252,6 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
alert, alert,
formTextInputXs: {
...fieldControlXs,
...fieldTextXs,
},
formTextInputSm: { formTextInputSm: {
...fieldControlSm, ...fieldControlSm,
...fieldTextSm, ...fieldTextSm,
@@ -283,10 +264,8 @@ export function createControlGeometry(theme: Theme) {
...fieldControlMd, ...fieldControlMd,
...fieldTextMd, ...fieldTextMd,
}, },
fieldControlXs,
fieldControlSm, fieldControlSm,
fieldControlMd, fieldControlMd,
fieldTextXs,
fieldTextSm, fieldTextSm,
fieldTextMd, fieldTextMd,
controlRest: { controlRest: {
@@ -1,23 +0,0 @@
import { createContext, useContext, type ReactNode } from "react";
/** The control sizes a container can ask of the controls placed in it. */
export type ContainedControlSize = "xs" | "sm";
// A row decides how big its controls are, so a button or field placed in it needs no size of
// its own. A size passed to the control still wins.
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
export function ControlSizeProvider({
size,
children,
}: {
size: ContainedControlSize;
children: ReactNode;
}) {
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
}
/** The size the surrounding container asks for, or null outside one. */
export function useContainedControlSize(): ContainedControlSize | null {
return useContext(ControlSizeContext);
}
@@ -9,7 +9,6 @@ import {
} from "@/components/ui/control-geometry"; } from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
import { useContainedControlSize } from "@/components/ui/control-size";
const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -33,12 +32,10 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
export function DropdownTrigger({ export function DropdownTrigger({
children, children,
leading, leading,
size: sizeProp, size = "sm",
disabled, disabled,
...props ...props
}: DropdownTriggerProps): ReactElement { }: DropdownTriggerProps): ReactElement {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "sm";
let sizeStyle: ViewStyle; let sizeStyle: ViewStyle;
if (size === "xs") { if (size === "xs") {
sizeStyle = styles.xs; sizeStyle = styles.xs;
+1 -2
View File
@@ -4,10 +4,9 @@ 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" | "surface3"; type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
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 }),
}; };
+3 -21
View File
@@ -23,16 +23,6 @@ import {
resolveControlInteractionStyles, resolveControlInteractionStyles,
type FieldControlSize, type FieldControlSize,
} from "@/components/ui/control-geometry"; } from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
FieldControlSize,
string
>;
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
FieldControlSize,
string
>;
interface FieldProps { interface FieldProps {
label: string; label: string;
@@ -169,13 +159,11 @@ function assignTextInputRef(
} }
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>( export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) { function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const isDisabled = editable === false; const isDisabled = editable === false;
const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]]; const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = formInputStyles[FIELD_TEXT[size]]; const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback( const setInputRef = useCallback(
(node: EditingTextInputHandle | null) => { (node: EditingTextInputHandle | null) => {
@@ -274,9 +262,6 @@ const formInputStyles = StyleSheet.create((theme) => {
chrome: { chrome: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
chromeXs: {
...geometry.fieldControlXs,
},
chromeSm: { chromeSm: {
...geometry.fieldControlSm, ...geometry.fieldControlSm,
}, },
@@ -304,9 +289,6 @@ const formInputStyles = StyleSheet.create((theme) => {
outlineColor: "transparent", outlineColor: "transparent",
outlineWidth: 0, outlineWidth: 0,
}, },
inputXs: {
...geometry.fieldTextXs,
},
inputSm: { inputSm: {
...geometry.fieldTextSm, ...geometry.fieldTextSm,
}, },
@@ -1,9 +1,5 @@
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
export const foregroundIconColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
export const mutedIconColorMapping = (theme: Theme) => ({ export const mutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
@@ -58,7 +58,6 @@ vi.mock("@gorhom/bottom-sheet", async () => {
}); });
import { IsolatedBottomSheetModal, type ContextBridge } from "."; import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */ /** Renders whatever the portal is currently holding, from its own place in the tree. */
@@ -201,35 +200,3 @@ describe("IsolatedBottomSheetModal context bridging", () => {
).toEqual(["false", "true"]); ).toEqual(["false", "true"]);
}); });
}); });
describe("bottom sheet layout subscription", () => {
it("stops updating a dismissed sheet when the window resizes", () => {
const container = document.createElement("div");
const root = createRoot(container);
let layout: ReturnType<typeof useAnimatedLayout> | undefined;
const viewport = document.documentElement;
const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth");
function resize(width: number) {
Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width });
act(() => window.dispatchEvent(new Event("resize")));
}
function LayoutProbe() {
layout = useAnimatedLayout(undefined, 0, 0);
return null;
}
try {
act(() => root.render(<LayoutProbe />));
if (!layout) throw new Error("Sheet layout did not mount");
const mountedLayout = layout;
resize(640);
expect(mountedLayout.get().window.width).toBe(640);
act(() => root.render(null));
resize(960);
expect(mountedLayout.get().window.width).toBe(640);
} finally {
act(() => root.unmount());
if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth);
else Reflect.deleteProperty(viewport, "clientWidth");
}
});
});
@@ -7,7 +7,6 @@ import {
segmentedIconSize, segmentedIconSize,
type SegmentedControlSize, type SegmentedControlSize,
} from "@/components/ui/control-geometry"; } from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
@@ -49,13 +48,11 @@ export function SegmentedControl<T extends string>({
options, options,
value, value,
onValueChange, onValueChange,
size: sizeProp, size = "md",
hideLabels = false, hideLabels = false,
style, style,
testID, testID,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const sizeStyles = { const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs }, xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm }, sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
@@ -1,89 +0,0 @@
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}
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}
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 },
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
}));
+17 -20
View File
@@ -1003,8 +1003,7 @@ function ComposerAutocompleteBinding({
recall, recall,
configuration, configuration,
inputRef, inputRef,
children, anchorRef,
anchorStyle,
show, show,
ref, ref,
}: { }: {
@@ -1016,8 +1015,7 @@ function ComposerAutocompleteBinding({
"userInput" | "cursorIndex" | "onAutocompleteApplied" "userInput" | "cursorIndex" | "onAutocompleteApplied"
>; >;
inputRef: React.RefObject<MessageInputRef | null>; inputRef: React.RefObject<MessageInputRef | null>;
children: React.ReactNode; anchorRef: React.RefObject<View | null>;
anchorStyle: React.ComponentProps<typeof View>["style"];
show: boolean; show: boolean;
ref: React.Ref<ComposerAutocompleteHandle>; ref: React.Ref<ComposerAutocompleteHandle>;
}) { }) {
@@ -1044,7 +1042,7 @@ function ComposerAutocompleteBinding({
return ( return (
<ComposerAutocomplete <ComposerAutocomplete
visible={autocomplete.isVisible && isShown} visible={autocomplete.isVisible && isShown}
anchorStyle={anchorStyle} anchorRef={anchorRef}
options={autocomplete.options} options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex} selectedIndex={autocomplete.selectedIndex}
onSelect={onSelect} onSelect={onSelect}
@@ -1052,9 +1050,7 @@ function ComposerAutocompleteBinding({
errorMessage={autocomplete.errorMessage} errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText} loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText} emptyText={autocomplete.emptyText}
> />
{children}
</ComposerAutocomplete>
); );
} }
@@ -2383,6 +2379,7 @@ function ComposerContentImpl({
pluginClientSlashCommands, pluginClientSlashCommands,
], ],
); );
const messageInputContainerRef = useRef<View>(null);
const isSubmitLoadingVisible = const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
@@ -2441,16 +2438,17 @@ function ComposerContentImpl({
{isFullscreen ? null : queueList} {isFullscreen ? null : queueList}
{!isFullscreen && sendErrorNode} {!isFullscreen && sendErrorNode}
<ComposerAutocompleteBinding <View ref={messageInputContainerRef} style={messageInputContainerStyle}>
text={textSource} <ComposerAutocompleteBinding
cursor={cursor} text={textSource}
recall={recall} cursor={cursor}
inputRef={messageInputRef} recall={recall}
anchorStyle={messageInputContainerStyle} inputRef={messageInputRef}
show={mode.showAutocomplete && !isFullscreen} anchorRef={messageInputContainerRef}
ref={autocompleteRef} show={mode.showAutocomplete && !isFullscreen}
configuration={autocompleteConfiguration} ref={autocompleteRef}
> configuration={autocompleteConfiguration}
/>
<ComposerForgeBinding <ComposerForgeBinding
text={textSource} text={textSource}
configuration={forgeConfiguration} configuration={forgeConfiguration}
@@ -2495,7 +2493,6 @@ 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}
@@ -2531,7 +2528,7 @@ function ComposerContentImpl({
renderOption={renderGithubPickerOption} renderOption={renderGithubPickerOption}
/> />
{pluginAttachments.picker} {pluginAttachments.picker}
</ComposerAutocompleteBinding> </View>
</View> </View>
</View> </View>
</View> </View>
+10 -62
View File
@@ -1,4 +1,3 @@
import type { SendShortcut } from "@/hooks/use-settings";
import { import {
useComposerEditingSession, useComposerEditingSession,
useComposerFullscreen, useComposerFullscreen,
@@ -28,7 +27,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { hasFinePointer } from "@/hooks/use-fine-pointer";
import { DictationOverlay } from "@/components/dictation-controls"; import { DictationOverlay } from "@/components/dictation-controls";
@@ -59,7 +57,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
@@ -78,7 +76,6 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
})); }));
import { import {
resolveSendTooltipLabel, resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel, resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel, resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText, resolveVoiceTooltipText,
@@ -86,10 +83,8 @@ import {
import { import {
applyDictationTranscript, applyDictationTranscript,
computeCanStartDictation, computeCanStartDictation,
resolveAlternateSendAction,
resolveComposerSurfacePresentation, resolveComposerSurfacePresentation,
resolveComposerSurfaceText, resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection, resolveRetainedSelection,
runAlternateSendAction, runAlternateSendAction,
runDefaultSendAction, runDefaultSendAction,
@@ -98,11 +93,6 @@ 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 {
@@ -174,7 +164,6 @@ 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. */
@@ -410,7 +399,6 @@ 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; isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean; isSubmitDisabled: boolean;
@@ -439,24 +427,18 @@ function handleDesktopKeyPressImpl(
} }
if (event.nativeEvent.key !== "Enter") return; if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
const action = resolveMessageInputEnterAction({ if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
submitOnEnter: ctx.submitOnEnter, if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
sendShortcut: ctx.sendShortcut, event.preventDefault();
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;
event.preventDefault();
if (action === "alternate-send") {
ctx.handleAlternateSendAction(); ctx.handleAlternateSendAction();
return; return;
} }
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
ctx.handleDefaultSendAction(); ctx.handleDefaultSendAction();
} }
@@ -787,10 +769,8 @@ function SendButtonTooltip({
canPressLoadingButton, canPressLoadingButton,
onSubmitLoadingPress, onSubmitLoadingPress,
onDefaultSendAction, onDefaultSendAction,
onLongPressSend,
isSendButtonDisabled, isSendButtonDisabled,
submitAccessibilityLabel, submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle, sendButtonCombinedStyle,
isSubmitLoading, isSubmitLoading,
submitIcon, submitIcon,
@@ -804,10 +784,8 @@ function SendButtonTooltip({
canPressLoadingButton: boolean; canPressLoadingButton: boolean;
onSubmitLoadingPress: (() => void) | undefined; onSubmitLoadingPress: (() => void) | undefined;
onDefaultSendAction: () => void; onDefaultSendAction: () => void;
onLongPressSend: () => void;
isSendButtonDisabled: boolean; isSendButtonDisabled: boolean;
submitAccessibilityLabel: string; submitAccessibilityLabel: string;
submitAccessibilityHint: string | undefined;
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"]; sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
isSubmitLoading: boolean; isSubmitLoading: boolean;
submitIcon: "arrow" | "return"; submitIcon: "arrow" | "return";
@@ -822,10 +800,8 @@ function SendButtonTooltip({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction} onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction}
onLongPress={canPressLoadingButton ? undefined : onLongPressSend}
disabled={isSendButtonDisabled} disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel} accessibilityLabel={submitAccessibilityLabel}
accessibilityHint={submitAccessibilityHint}
accessibilityRole="button" accessibilityRole="button"
testID={submitButtonTestID} testID={submitButtonTestID}
style={sendButtonCombinedStyle} style={sendButtonCombinedStyle}
@@ -1132,7 +1108,6 @@ 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;
@@ -1180,7 +1155,6 @@ 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,
@@ -1258,7 +1232,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId, voiceAgentId,
isAgentRunning, isAgentRunning,
defaultSendBehavior, defaultSendBehavior,
sendShortcut,
onQueue, onQueue,
onSubmitLoadingPress, onSubmitLoadingPress,
onKeyPressCallback, onKeyPressCallback,
@@ -1732,19 +1705,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}); });
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
const alternateSendAction = resolveAlternateSendAction({
defaultSendBehavior,
isAgentRunning,
onQueue,
});
const handleLongPressSend = useCallback(() => {
if (alternateSendAction && isNative) {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
}
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteImagesEffect({ usePasteImagesEffect({
textarea: webTextArea, textarea: webTextArea,
isConnected, isConnected,
@@ -1778,7 +1738,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current, selectionRef.current,
), ),
submitOnEnter: shouldSubmitOnEnter, submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning, isAgentRunning,
onQueue, onQueue,
isSubmitDisabled, isSubmitDisabled,
@@ -1792,7 +1751,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress, shouldHandleWebKeyPress,
onKeyPressCallback, onKeyPressCallback,
shouldSubmitOnEnter, shouldSubmitOnEnter,
sendShortcut,
isAgentRunning, isAgentRunning,
onQueue, onQueue,
isSubmitDisabled, isSubmitDisabled,
@@ -1834,11 +1792,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning, isAgentRunning,
t, t,
}); });
const submitAccessibilityHint = resolveSubmitAccessibilityHint({
canPressLoadingButton,
alternateSendAction,
t,
});
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent, isRealtimeVoiceForCurrentAgent,
@@ -1999,17 +1952,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton} canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress} onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction} onDefaultSendAction={handleDefaultSendAction}
onLongPressSend={handleLongPressSend}
isSendButtonDisabled={isPresentedSendDisabled} isSendButtonDisabled={isPresentedSendDisabled}
submitAccessibilityLabel={submitAccessibilityLabel} submitAccessibilityLabel={submitAccessibilityLabel}
submitAccessibilityHint={submitAccessibilityHint}
sendButtonCombinedStyle={sendButtonCombinedStyle} sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading} isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon} submitIcon={submitIcon}
submitLabel={submitLabel} submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID} submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
sendKeys={resolveSendKeys(sendShortcut)} sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel} sendTooltipLabel={sendTooltipLabel}
/> />
), ),
@@ -2020,17 +1971,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton, canPressLoadingButton,
onSubmitLoadingPress, onSubmitLoadingPress,
handleDefaultSendAction, handleDefaultSendAction,
handleLongPressSend,
isPresentedSendDisabled, isPresentedSendDisabled,
submitAccessibilityLabel, submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle, sendButtonCombinedStyle,
isSubmitLoading, isSubmitLoading,
submitIcon, submitIcon,
submitLabel, submitLabel,
submitButtonTestID, submitButtonTestID,
buttonIconSize, buttonIconSize,
sendShortcut,
sendTooltipLabel, sendTooltipLabel,
], ],
); );
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
resolveSendTooltipLabel, resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel, resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel, resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText, resolveVoiceTooltipText,
@@ -13,8 +12,6 @@ const translations: Record<string, string> = {
"composer.input.sendAndInterrupt": "Send and interrupt", "composer.input.sendAndInterrupt": "Send and interrupt",
"composer.input.sendAndSteer": "Send and steer", "composer.input.sendAndSteer": "Send and steer",
"composer.input.sendMessage": "Send message", "composer.input.sendMessage": "Send message",
"composer.input.longPressToQueue": "Long press to queue",
"composer.input.longPressToSendNow": "Long press to send now",
"composer.input.queue": "Queue", "composer.input.queue": "Queue",
"composer.input.send": "Send", "composer.input.send": "Send",
"composer.voice.unmuteVoiceMode": "Unmute Voice mode", "composer.voice.unmuteVoiceMode": "Unmute Voice mode",
@@ -29,37 +26,6 @@ const translations: Record<string, string> = {
const t = ((key: string) => translations[key] ?? key) as never; const t = ((key: string) => translations[key] ?? key) as never;
describe("composer input labels", () => { describe("composer input labels", () => {
it("names the long-press action in the submit hint only when one exists", () => {
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: "queue",
t,
}),
).toBe("Long press to queue");
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: "send",
t,
}),
).toBe("Long press to send now");
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: null,
t,
}),
).toBeUndefined();
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: true,
alternateSendAction: "queue",
t,
}),
).toBeUndefined();
});
it("resolves submit accessibility labels from translations", () => { it("resolves submit accessibility labels from translations", () => {
expect( expect(
resolveSubmitAccessibilityLabel({ resolveSubmitAccessibilityLabel({
-14
View File
@@ -1,6 +1,5 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { SendBehavior } from "@/hooks/use-settings/storage"; import type { SendBehavior } from "@/hooks/use-settings/storage";
import type { AlternateSendAction } from "./state";
export function resolveSubmitAccessibilityLabel(input: { export function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined; submitButtonAccessibilityLabel: string | undefined;
@@ -23,19 +22,6 @@ export function resolveSubmitAccessibilityLabel(input: {
return input.t("composer.input.sendMessage"); return input.t("composer.input.sendMessage");
} }
export function resolveSubmitAccessibilityHint(input: {
canPressLoadingButton: boolean;
alternateSendAction: AlternateSendAction | null;
t: TFunction;
}): string | undefined {
if (input.canPressLoadingButton || !input.alternateSendAction) return undefined;
return input.t(
input.alternateSendAction === "queue"
? "composer.input.longPressToQueue"
: "composer.input.longPressToSendNow",
);
}
export function resolveVoiceAccessibilityLabel(input: { export function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean; isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean; isMuted: boolean;
@@ -2,14 +2,11 @@ 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,
runAlternateSendAction, runAlternateSendAction,
runDefaultSendAction, runDefaultSendAction,
runMessageInputKeyboardAction, runMessageInputKeyboardAction,
@@ -279,253 +276,6 @@ describe("composer send behavior", () => {
expect(defaultAction.calls).toEqual(["queue"]); expect(defaultAction.calls).toEqual(["queue"]);
expect(alternateAction.calls).toEqual(["send"]); expect(alternateAction.calls).toEqual(["send"]);
}); });
it("sends with the alternate action while no turn is running", () => {
for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) {
const alternateAction = actions();
runAlternateSendAction({
defaultSendBehavior,
isAgentRunning: false,
onQueue: alternateAction.onQueue,
handleSendMessage: alternateAction.handleSendMessage,
handleQueueMessage: alternateAction.handleQueueMessage,
});
expect(alternateAction.calls).toEqual(["send"]);
}
});
it("offers an alternate action only while a turn runs and the composer can queue", () => {
const onQueue = () => undefined;
expect(
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }),
).toBe("queue");
expect(
resolveAlternateSendAction({
defaultSendBehavior: "interrupt",
isAgentRunning: true,
onQueue,
}),
).toBe("queue");
expect(
resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }),
).toBe("send");
expect(
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }),
).toBeNull();
expect(
resolveAlternateSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: undefined,
}),
).toBeNull();
});
});
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", () => {
+4 -44
View File
@@ -1,4 +1,3 @@
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";
@@ -126,53 +125,14 @@ export function runDefaultSendAction(ctx: SendActionContext): void {
ctx.handleSendMessage(); ctx.handleSendMessage();
} }
export type AlternateSendAction = "queue" | "send";
/**
* What Mod+Enter and a long-press on Send do instead of the default send. Null while
* no turn is running, where both fall back to the default send.
*/
export function resolveAlternateSendAction(
ctx: Pick<SendActionContext, "defaultSendBehavior" | "isAgentRunning" | "onQueue">,
): AlternateSendAction | null {
if (!ctx.isAgentRunning || !ctx.onQueue) return null;
return ctx.defaultSendBehavior === "queue" ? "send" : "queue";
}
export function runAlternateSendAction(ctx: SendActionContext): void { export function runAlternateSendAction(ctx: SendActionContext): void {
const action = resolveAlternateSendAction(ctx); if (ctx.defaultSendBehavior === "queue") {
if (action === "queue") {
ctx.handleQueueMessage();
return;
}
if (action === "send") {
ctx.handleSendMessage(); ctx.handleSendMessage();
return; return;
} }
runDefaultSendAction(ctx); if (ctx.isAgentRunning && ctx.onQueue) {
} ctx.handleQueueMessage();
}
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(

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